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/
Related
- Pattern walkthrough: BOLA in AI-generated CRUD — the missing ownership check
- Pattern walkthrough: Mass assignment — when the AI hands the user is_admin: true
- Pattern walkthrough: The Supabase service-role key in your frontend bundle (because service-role bypasses the RLS layer that would have caught BOLA)
- Data study: 2026 AI App Security Benchmark
- Data study: Supabase RLS in the Wild
- Data study: Where Vibe Coders Leak Their Keys
- Guide: Solo Founder Pre-Launch Security Checklist
- Tool: Vibe Code Scanner
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.
curl -s https://gapbench.vibe-eval.com/site/multi-tenant-saas/api/projects/1 -H 'Authorization: Bearer USER_B_TOKEN'
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}'
curl -s -X PATCH https://gapbench.vibe-eval.com/site/indie-saas/api/invoices/42 -H 'Authorization: Bearer USER_B_TOKEN' -d '{"status":"paid"}'
curl -s https://gapbench.vibe-eval.com/site/ref-rls/api/invoices/1 -H 'Authorization: Bearer USER_B_TOKEN'
COMMON QUESTIONS
TEST YOUR APP FOR BOLA
VibeEval enumerates IDs across roles to prove BOLA in your live app. 60 seconds, no setup.