BROKEN OBJECT-LEVEL AUTH IN AI-GENERATED CRUD
BOLA — Broken Object-Level Authorization — is the #1 OWASP API risk and the most-reproduced application-layer authorization failure in AI-built apps. This catalog ranks the failure shapes by HTTP verb, names the resource paths most often vulnerable, and gives a per-stack fix pattern. Every shape is reproducible against a live gapbench scenario.
We test every AI-built app that exposes a CRUD-style API against the OWASP API #1 risk: BOLA. This catalog documents the failure shapes — by HTTP method, by resource name, by platform-default scaffold — that we reproduce most consistently on the gapbench public benchmark and in anonymized customer engagements.
Catalog scope
| Field | Value |
|---|---|
| Window | Nov 2025 – Apr 2026 |
| Source | Anonymized customer engagements + gapbench reproducible scenarios |
| Reproducibility anchor | multi-tenant-saas, fintech-app, indie-saas |
| Calibration control | ref-rls — same CRUD shape, ownership check enforced, returns 404 |
We do not publish corpus-wide BOLA rates or per-platform route counts because the underlying engagement set is anonymized and not a uniform random sample. Every failure shape below is reproducible in seconds against the listed gapbench scenario.
By HTTP method
A route can be vulnerable on read but safe on write, or vice versa. Ranking reflects relative frequency of BOLA-on-this-verb that we observe across engagements.
| Method | Relative BOLA frequency | Why |
|---|---|---|
| PUT (replace) | Highest | Replaces row entirely; ownership rarely re-checked |
| PATCH (partial update) | High | Permissive update with field allow-list missing |
| GET (read) | High | AI generators filter by ID, skip ownership check |
| DELETE | Moderate | Often guarded behind a confirm step that runs server-side |
| POST (create) | Low | 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 name
Routes are classified by the resource name in the path. Naming gravity matters — AI generators converge on the same resource names, which makes the listed paths both more numerous and more attackable. Ranking is relative frequency in engagements.
| Rank | Resource | Why it ranks here |
|---|---|---|
| 1 | /api/invoices/:id |
High-value owned resource; clear single owner, no expected sharing |
| 2 | /api/messages/:id |
Owned by sender; ownership scope often skipped in scaffold |
| 3 | /api/projects/:id |
Multi-tenant SaaS scaffold default |
| 4 | /api/profiles/:id |
Mirrors Supabase Auth profiles; scope drift across tenants |
| 5 | /api/orders/:id |
E-commerce scaffold; ownership policy often incomplete |
| 6 | /api/tasks/:id |
Task-manager scaffold default |
| 7 | /api/comments/:id |
Generated alongside threaded-content features |
| 8 | /api/notes/:id |
Notes-app scaffold default |
| 9 | /api/files/:id |
Upload scaffold; ownership check often missing |
| 10 | /api/settings/:id |
Lower-frequency; settings often gated through user-scoped middleware |
invoices and messages rank highest 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 modal vulnerable verb
The class of BOLA failure that lands first per platform, driven by the platform’s default scaffolding patterns.
| Platform | Relative BOLA incidence | Modal vulnerable verb |
|---|---|---|
| Lovable | Highest | PATCH |
| Bolt.new | High | GET |
| Replit | High | PUT |
| Cursor | Moderate | GET |
| V0 (with API backend) | Lower | 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 catalog.
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
Source. Failure shapes were identified across (a) anonymized customer engagements with apps built on Lovable, Bolt.new, Cursor, Replit, and V0 between Nov 2025 and Apr 2026 — restricted to apps exposing at least one CRUD-style API path (/api/<resource>/:id or equivalent on Supabase PostgREST, Hasura GraphQL, Firebase Functions) — and (b) deliberately vulnerable scenarios on the gapbench public benchmark. We do not publish a corpus N because the engagement portion is anonymized by design.
Probe. For each candidate route, we create two test users, have user A produce a resource, then have user B attempt to read, modify, and delete user A’s resource. A response containing user A’s data classifies as BOLA. A 401 / 403 / 404 classifies as safe. Ambiguous responses (e.g. empty 200) are 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 test only routes that are 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-represented.
Calibration against ref-rls. 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. Every recurrence claim in this catalog is 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.
Sources and references
- gapbench BOLA scenarios. multi-tenant-saas, fintech-app, indie-saas, ref-rls (clean control).
- OWASP API1:2023 Broken Object Level Authorization — owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization.
- OWASP API3:2023 Broken Object Property Level Authorization — owasp.org/API-Security/editions/2023/en/0xa3-broken-object-property-level-authorization.
- CWE-639 Authorization Bypass Through User-Controlled Key, CWE-284 Improper Access Control, CWE-285 Improper Authorization, CWE-863 Incorrect Authorization. cwe.mitre.org.
- NIST SP 800-53 AC-3 Access Enforcement, AC-6 Least Privilege. csrc.nist.gov/publications/detail/sp/800-53/rev-5/final.
- Companion pattern walkthrough. BOLA in AI-generated CRUD — the anatomy + fix recipes per stack.
Citations
VibeEval. Broken Object-Level Auth in AI-Generated CRUD. 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.