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

Citations

VibeEval. Broken Object-Level Auth in AI-Generated CRUD. 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 a large share of CRUD routes in AI-built apps; it is the modal application-layer authorization failure in this category.
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