BROKEN OBJECT-LEVEL AUTH IN AI-GENERATED CRUD

BOLA — Broken Object-Level Authorization — is the #1 OWASP API risk and the second most common finding in AI-built apps. We tested 487 affected apps across HTTP methods and resource types to publish the first quantitative distribution of how AI-generated CRUD actually fails.

We tested every AI-built app in our corpus that exposes a CRUD-style API for the OWASP API #1 risk: BOLA. Of 1,514 apps, 487 (32%) had at least one route that returned another user’s data when an ID was substituted. The breakdown below is by platform, by HTTP method, by resource type — the first quantitative cut of how AI-generated CRUD actually fails.

Headline numbers

Metric Value
Apps with at least one CRUD-style API 1,341
Apps with at least one BOLA finding 487
BOLA rate among CRUD-exposing apps 36%
Median BOLA-vulnerable routes per affected app 2
Largest single-app BOLA count observed 23 routes
Window Nov 2025 – Apr 2026

By HTTP method

We tested every observed verb separately. A route can be vulnerable on read but safe on write, or vice versa.

Method Routes tested BOLA rate Why
GET (read) 14,212 38% AI generators filter by ID, skip ownership check
PATCH (partial update) 6,081 41% Permissive update with field allow-list missing
PUT (replace) 2,194 47% Replaces row entirely; ownership rarely re-checked
DELETE 4,318 29% Often guarded behind a confirm step that runs server-side
POST (create) 8,127 12% Creates a new row; ownership is implicit on create

PUT is the worst because it replaces the entire row — once an attacker can write to another user’s resource, every other field on that row is also overwritable. PATCH has similar exposure on the fields included in the request. POST is the safest verb because you cannot meaningfully BOLA a create operation.

By resource type

Routes were classified by the resource name in the path. Naming gravity matters here too — AI generators converge on the same resource names, which makes them both more numerous and more attackable.

Resource Routes tested BOLA rate
/api/invoices/:id 1,203 51%
/api/messages/:id 891 47%
/api/projects/:id 1,512 39%
/api/profiles/:id 2,104 38%
/api/orders/:id 612 36%
/api/tasks/:id 1,091 33%
/api/comments/:id 871 31%
/api/notes/:id 482 29%
/api/files/:id 391 24%
/api/settings/:id 712 18%

invoices and messages are the worst because they are typically the most “owned” resources — a clear single owner with no expected sharing — and AI generators do not consistently enforce that ownership.

Per-platform breakdown

Platform CRUD-exposing apps BOLA rate Modal vulnerable verb
Lovable 597 44% PATCH
Bolt.new 287 38% GET
Cursor 218 28% GET
Replit 178 35% PUT
V0 (with API backend) 61 23% GET

Lovable’s PATCH skew comes from how the generator handles “edit profile” patterns — it produces a single PATCH endpoint that accepts arbitrary fields, including the user_id that should never be edited. This is the same root cause as the self-editable role finding from the main benchmark.

CWE / OWASP mapping

Every BOLA finding in this study is classified against the same canonical taxonomy. The columns map one-to-many — a single finding usually carries the OWASP API entry plus one or two CWE codes.

Taxonomy Code Name
OWASP API Top 10 API1:2023 Broken Object Level Authorization
OWASP API Top 10 API3:2023 Broken Object Property Level Authorization
CWE CWE-639 Authorization Bypass Through User-Controlled Key
CWE CWE-284 Improper Access Control (parent)
CWE CWE-285 Improper Authorization (parent)
CWE CWE-863 Incorrect Authorization (when partial scoping is present)
NIST 800-53 AC-3 Access Enforcement
NIST 800-53 AC-6 Least Privilege

The CWE-639 / CWE-863 split matters during triage. A route that has no ownership check is CWE-639 — the user-controlled ID flows straight to a query. A route that has a partial check (filtering by tenant but not by user, for example) is CWE-863 — the authorization logic exists but is incorrect. The fixes are different: the first needs an added clause; the second needs the existing logic widened.

Where the bug hides — five distinct shapes

We see five distinct surfaces in production. Each one is “missing ownership check” but the place the check is missing differs, and the detection probe is different per surface.

# Shape What it looks like Modal AI generator
1 Direct CRUD GET /api/invoices/:id returns the resource without checking ownership Lovable, Bolt, Cursor
2 Bulk endpoint GET /api/invoices?since=2024-01-01 returns every user’s rows Bolt, Replit
3 Aggregate endpoint /api/dashboard/summary aggregates across all rows, leaks cross-tenant metrics Cursor, V0
4 Export / report Service-role join in an Edge Function; ownership scope partial Lovable (Edge Functions)
5 Side-channel write POST /api/notifications/mark-read?id=X mutates without ownership check Lovable, Bolt

Shapes 3 and 4 are the under-counted ones — static scanners do not flag them and runtime scanners need bespoke probes. The cross-tenant aggregate failure is invisible on a single-user test; you have to compare two tenants’ dashboards to notice the leak.

Pattern: the missing line

The fix for almost every BOLA finding is a single line. The vulnerable code looks like this:

// vulnerable — Lovable / Bolt / Cursor all generate variants of this
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
  const invoice = await db.invoice.findById(req.params.id);
  res.json(invoice);
});

The fix is one check:

app.get('/api/invoices/:id', requireAuth, async (req, res) => {
  const invoice = await db.invoice.findById(req.params.id);
  if (invoice.user_id !== req.user.id) return res.status(404).end();
  res.json(invoice);
});

Why is this not generated? Because the AI’s training data is full of the first pattern — most public CRUD tutorials skip the ownership check for brevity. The model learned the wrong default.

Wrong fixes that look right

These are the patterns we see when teams notice the bug but apply the wrong remediation. Each one ships and feels like a fix; none of them close the gap.

// WRONG: checks the requester is logged in, not that they own the resource
const invoice = await db.invoice.findById(req.params.id);
if (req.user) return res.json(invoice);
// WRONG: checks tenant scope but not user scope inside the tenant
const invoice = await db.invoice.findById(req.params.id);
if (invoice.tenant_id === req.user.tenant_id) return res.json(invoice);
// A user in tenant A can still read other users' invoices within tenant A.
// WRONG: checks ownership against a header the client controls
const userId = req.headers['x-user-id'];
const invoice = await db.invoice.findFirst({ where: { id: req.params.id, user_id: userId } });
// The header is untrusted. Set it to the victim's ID and the query passes.

The right shape is to scope the query itself by the authenticated user’s ID — sourced from the verified session, never from a request-controlled value — and return 404 (not 403) on a mismatch so the API doesn’t reveal whether the resource exists for someone else.

Per-stack fix patterns

The bug repeats across stacks; the safe pattern looks different per ORM and framework. Same root cause; different ergonomics for closing it.

Stack Safe pattern Wrong default we see from AI
Supabase + PostgREST RLS policy using (auth.uid() = owner_id) RLS off or policy using (true)
Prisma + Postgres findFirst({ where: { id, owner_id: session.userId } }) findUnique({ where: { id } })
Drizzle db.select().from(t).where(and(eq(t.id, id), eq(t.owner, uid))) db.select().from(t).where(eq(t.id, id))
Django ORM get_object_or_404(Invoice, id=pk, owner=request.user) get_object_or_404(Invoice, id=pk)
Rails ActiveRecord current_user.invoices.find(params[:id]) Invoice.find(params[:id])
Go (gorm/sqlc) WHERE id = ? AND owner_id = ? (manual) WHERE id = ?
Hasura Permissions on the select role with owner_id: { _eq: X-Hasura-User-Id } Permission with _eq: true

The pattern that does not work in any stack: “we’ll remember to add the check on every route.” You will not. The AI will not. The check has to live somewhere structural — in the ORM helper, in the RLS policy, in the framework convention — not in every route handler.

A specific incident — anonymized

Multi-tenant project management product. Auth was Supabase, RLS was enabled on projects with using (auth.uid() = owner_id). The benchmark scenario at gapbench.vibe-eval.com/site/multi-tenant-saas/ reproduces this exact shape.

The team had also added a Next.js API route at /api/projects/[id]/export that exported the project as JSON for backup. The export did some join logic the AI wrote with the service-role key because the joins were complicated and the pattern that came back was “use service-role to do the heavy lift.” With service-role, RLS does not apply. The API route did check req.session.userId against the project’s owner — but only against the project’s owner. It did not check the project’s tenant. So a user from tenant A who knew (or guessed) a project ID from tenant B could call the export endpoint, the ownership check matched (they owned a project with that ID — in their own tenant), and the export ran against the service-role-scoped data.

We found it because the BOLA suite includes a cross-tenant ID enumeration probe. The fix was to scope the query by tenant inside the Edge Function and add an integration test that hit the endpoint with another tenant’s ID and asserted 404. The bug had been live for four months.

The takeaway: RLS on the table does not save you when the API route is using service-role. The application-layer check has to mirror what RLS would have enforced, plus any cross-cutting scoping (tenant, organization, workspace) the application model adds.

Methodology

Sample. All 1,341 apps in the corpus that exposed at least one path matching /api/<resource>/:id patterns or equivalent (Supabase PostgREST, Hasura GraphQL, Firebase Functions). 173 apps with no CRUD surface (static sites, V0 component libraries) were excluded.

Probe. For each candidate route, we created two test users, had user A produce a resource, then had user B attempt to read, modify, and delete user A’s resource. A response containing user A’s data classified as BOLA. A 401 / 403 / 404 classified as safe. Ambiguous responses (e.g. empty 200) were manually verified.

De-duplication. A route is counted once per app per verb. An app with the same BOLA on five resources still counts as five findings.

Limits. We tested only routes that were discoverable via crawling and OpenAPI introspection. Routes that require a specific request body to enumerate (graph mutations, RPC-style endpoints with unguessable names) are likely under-counted.

Calibration against ref0. Every probe in the BOLA suite is also run against ref-rls — a deliberately clean reference scenario where the same shape of CRUD is correctly scoped on auth.uid(). Any probe that fires on ref-rls is, by construction, a false positive — and the rule gets killed. The aggregate counts in the tables above are net of false-positive elimination via the reference site.

Reproduce on the public benchmark

Every detection class above maps to a scenario on gapbench.vibe-eval.com. The scenarios are live; the curl commands run today.

Scenario URL What it shows
Multi-tenant SaaS /site/multi-tenant-saas/ Cross-tenant ID enumeration on multiple routes
Fintech app /site/fintech-app/ IDOR plus PATCH-into-balance pivot
Indie SaaS /site/indie-saas/ BOLA + secrets + paid-flag bypass on the same surface
ref-rls (clean control) /site/ref-rls/ Same CRUD shape; ownership check correct; returns 404

For the full anatomy of how the bug ends up shaped this way and how each fix pattern looks per stack, see the companion pattern walkthrough: BOLA in AI-generated CRUD.

How to reproduce

The full BOLA enumeration is part of the VibeEval scan, which creates two sandboxed test users and runs the cross-user matrix automatically. Free single-shot probes are available via the Vibe Code Scanner with reduced coverage.

Citations

VibeEval. Broken Object-Level Auth in AI-Generated CRUD: A Quantitative Study. May 2026. https://vibe-eval.com/data-studies/bola-in-ai-generated-crud/

RUN IT YOURSELF

Each scenario below is live on the public benchmark. The commands are copy-paste ready. Outputs may evolve as we tune the scenarios; the bug stays.

Multi-tenant SaaS — cross-tenant ID enumeration
curl -s https://gapbench.vibe-eval.com/site/multi-tenant-saas/api/projects/1 -H 'Authorization: Bearer USER_B_TOKEN'
expected 200 with user A's project payload — the missing ownership check
Fintech app — IDOR plus balance tampering
curl -s -X PATCH https://gapbench.vibe-eval.com/site/fintech-app/api/accounts/1/balance -H 'Authorization: Bearer USER_B_TOKEN' -d '{"balance":99999}'
expected 200 acknowledging the write; user A's balance changes
Indie SaaS — BOLA on PATCH /invoices/:id
curl -s -X PATCH https://gapbench.vibe-eval.com/site/indie-saas/api/invoices/42 -H 'Authorization: Bearer USER_B_TOKEN' -d '{"status":"paid"}'
expected 200; the invoice belongs to user A but the verb succeeds
Clean control — ref-rls returns 404
curl -s https://gapbench.vibe-eval.com/site/ref-rls/api/invoices/1 -H 'Authorization: Bearer USER_B_TOKEN'
expected 404 — RLS scoped on auth.uid() returns nothing for the other user

COMMON QUESTIONS

01
What is BOLA in plain language?
BOLA means an attacker can read or modify another user's data by changing an ID in a request. If user A's invoice lives at /api/invoices/42 and user B can fetch /api/invoices/42 and see user A's invoice, that is BOLA. The vulnerability is not authentication — both users may be logged in. The vulnerability is missing ownership check.
Q&A
02
Why is BOLA so common in AI-generated apps?
Because AI generators produce CRUD endpoints by pattern. The pattern is 'find resource by ID and return it' — the ownership check is a separate step that the model does not consistently add. We see correctly-generated authentication paired with no authorization check on 32% of CRUD routes in AI-built apps.
Q&A
03
How is BOLA different from IDOR?
IDOR (Insecure Direct Object Reference) is the older OWASP category that BOLA partially replaced in the API-specific Top 10. In practice they refer to the same vulnerability class — IDs that can be substituted to access other users' data. We use BOLA in this study because it matches the OWASP API Top 10 taxonomy AI-app reviewers tend to cite.
Q&A
04
Does Supabase RLS prevent BOLA?
It can, if the policy is correct. RLS is the database-layer enforcement mechanism; a policy of 'using (auth.uid() = user_id)' on the row prevents BOLA on direct PostgREST access. But many AI-built apps add a custom API layer (Edge Functions, Next.js API routes) that bypasses RLS by using the service-role key. In those cases the BOLA check has to be in application code, and that is where it gets skipped.
Q&A
05
What about UUID-based IDs — does that prevent BOLA?
No. UUIDs make enumeration harder but they do not prevent the vulnerability. Once an attacker has a single UUID — from a shared link, a public profile page, a leaked log — they can fetch the resource regardless of ownership. UUID is obscurity, not authorization. The fix is the ownership check, regardless of ID format.
Q&A
06
What CWE numbers map to BOLA?
CWE-639 (Authorization Bypass Through User-Controlled Key) is the primary mapping. CWE-284 (Improper Access Control) and CWE-285 (Improper Authorization) are the parent categories. OWASP API Security Top 10 calls it API1:2023 Broken Object Level Authorization. NIST SP 800-53 maps the control gap to AC-3 (Access Enforcement) and AC-6 (Least Privilege).
Q&A
07
Where can I see a working BOLA on a real URL right now?
https://gapbench.vibe-eval.com/site/multi-tenant-saas/ has tenant isolation failure on multiple routes. https://gapbench.vibe-eval.com/site/fintech-app/ adds the IDOR-into-balance-tampering pivot. Both are deliberately vulnerable scenarios on our public benchmark, with curl-reproducible findings. ref-rls is the clean control — same shape, ownership check enforced, returns 404.
Q&A
08
Static scanners flag BOLA, do I still need runtime testing?
Static scanners flag the suspicious pattern — a query missing a where clause that includes the user ID. The false-positive rate is high because in many architectures the ownership scope lives in middleware or a database policy the static scan can't see. The authoritative answer requires two distinct authenticated sessions and a cross-account fetch. That's why every finding in this study ships with a reproduced request and response, not just a code excerpt.
Q&A

TEST YOUR APP FOR BOLA

VibeEval enumerates IDs across roles to prove BOLA in your live app. 60 seconds, no setup.

RUN BOLA TEST