BOLA IN AI-GENERATED CRUD
An AI generator gives you a CRUD endpoint in five seconds. It filters by ID. It does not check that the requesting user owns that ID. That gap, repeated across every controller in the app, is BOLA — and it is the most common authorization failure we find.
The scenario referenced below runs on gapbench.vibe-eval.com — a public security benchmark we operate. The client engagement that originally surfaced this pattern is anonymized; the gapbench scenario is the reproducible equivalent.
The bug that hides in plain sight
You have a CRUD app. There’s a route called GET /api/invoices/:id. It loads the invoice from the database where id = req.params.id and returns it. The endpoint requires a logged-in user — no anonymous reads. The session middleware works. JWTs verify. Auth is fine.
The bug is that “logged-in user” and “the user who owns this invoice” are different sentences, and the route only checks the first one.
This is BOLA. Two-letter difference from “BOLT” the AI codegen tool, ironically the platform we find it on most. An attacker logs in as themselves, hits /api/invoices/42, gets back an invoice belonging to a stranger. They can also change 42 to 43, 44, 45 until something interesting comes back. PATCH and DELETE often work too. The whole class of attack is just “change the number in the URL.”
Why does the AI keep doing this
Look at how a reasonable CRUD handler ends up shaped:
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
const invoice = await db.invoice.findUnique({ where: { id: req.params.id } })
if (!invoice) return res.status(404).end()
return res.json(invoice)
})
This passes a code review at a glance. There’s auth middleware. There’s a 404 path. The Prisma call uses findUnique. Looks fine.
What’s missing is the line if (invoice.user_id !== req.session.userId) return res.status(403).end(). That single absence is the entire bug. The AI does not add it because the tutorial code it learned from filters the query by ID, not by (id, owner). The model produces what it has seen. What it has seen is wrong.
The same shape repeats on PATCH (the AI updates by ID, no ownership check), on PUT (the AI replaces the whole row, no ownership check), on DELETE (the AI deletes by ID, no ownership check). Out of the four verbs, only POST is usually fine — creating a row implicitly assigns ownership to the requester, so the ownership question doesn’t arise. Every other verb is suspect by default.
We have measured this in our BOLA data study. PUT routes have a 47% BOLA rate. PATCH 41%. GET 38%. The numbers are large because the failure mode is uniform. Once you find the bug on one route, you find it on most of the others. Conversely, when a team has fixed it consciously, the fix is uniform too — they wrote a helper or used RLS, and the whole API is consistent.
A specific incident
Anonymized. The product was a multi-tenant project management tool. Each tenant (a company) had its own users, projects, and tasks. Auth was Supabase, RLS was enabled on the projects table — using (auth.uid() = owner_id). Looked fine.
Except 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 that the AI wrote with the service-role key because the joins were complicated and the pattern that came back from Cursor was “use service-role to do the heavy lift.” With service-role, RLS doesn’t apply. The API route did check req.session.userId against the project’s owner — but only against the project’s owner. It didn’t 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 did own 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 specifically for this pattern. The fix was to scope the query by tenant inside the Edge Function and to add an integration test that hit the endpoint with another tenant’s ID and asserted 404. The bug had been live for four months. Audit logs showed two suspicious export calls but the team couldn’t say if those were exploit attempts or a confused user.
The takeaway: RLS on the table doesn’t 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) that the application model adds.
The five places BOLA hides
We see five distinct shapes in production. They’re all “missing ownership check” but the place where the check is missing differs.
-
Direct CRUD endpoints.
/api/invoices/:idreturns the invoice. No check that the requester owns it. The simplest case; the most common. -
Bulk endpoints.
/api/invoices?since=2024-01-01returns invoices. Filter is by date. No filter by owner. Returns every user’s invoices. AI codegen produces this when the developer asks for “a paginated list endpoint” without specifying scope. -
Aggregate endpoints.
/api/dashboard/summaryreturns metrics. Metrics are “total revenue this month” — but the SQL aggregates across all rows, not just the user’s. The endpoint appears to be safe because no individual record comes back, but the aggregate leaks information about other tenants’ state. -
Export / report endpoints. Same shape as the incident above. The endpoint uses service-role to do a complex join, and the ownership check is in the application layer but doesn’t cover all the dimensions that RLS would have.
-
Side-channel writes.
/api/notifications/mark-read?id=42— writes to a notification record. If the write doesn’t check ownership, an attacker can mark every user’s notifications as read, denying them visibility into their own activity. Less impactful than reads but harder to detect because the impact is on the victim’s experience, not the attacker’s data.
Wrong fix vs right fix
// WRONG: ownership check is on the requester, not the resource
const invoice = await db.invoice.findUnique({ where: { id: req.params.id } })
if (req.session.userId) return res.json(invoice) // <- checks logged in, not ownership
// WRONG: ownership check is on a shared field that's not actually scoping
const invoice = await db.invoice.findUnique({ where: { id: req.params.id } })
if (invoice.tenant_id === req.session.tenantId) return res.json(invoice)
// Misses: a user inside tenant A might still not be allowed to see another user's invoice
// RIGHT: query is scoped by both tenant and owner
const invoice = await db.invoice.findUnique({
where: { id: req.params.id, owner_id: req.session.userId, tenant_id: req.session.tenantId }
})
if (!invoice) return res.status(404).end()
return res.json(invoice)
// Note 404 not 403 - don't reveal whether the resource exists for someone else
Cross-stack notes
The pattern repeats with the same root cause across stacks. Different ORMs / frameworks make the safe pattern more or less ergonomic.
- Prisma + Supabase: RLS is the safest layer; query through PostgREST keeps it enforced. Mixing service-role with application code re-introduces the risk.
- Drizzle: No RLS layer; ownership checks live in application code. Higher discipline burden.
- Django:
get_object_or_404(Invoice, id=pk, owner=request.user)is the idiomatic safe pattern. Skipping theowner=request.userpart is the bug. - Rails:
current_user.invoices.find(params[:id])scopes through the association. AI codegen sometimes writesInvoice.find(params[:id])because that’s the simpler API. - Go (gorm, sqlc): Manual; no association-based safety. Every endpoint has to write the WHERE clause explicitly. Higher rate of “forgot to add owner” bugs in our scans.
What this looks like live
Hit https://gapbench.vibe-eval.com/site/multi-tenant-saas/. Sign up as user A. Note the URL of one of your resources. Sign up as user B. Hit user A’s resource URL. The data comes back. That’s the bug. There is no clever exploit chain — the chain is “increment a number.”
The fintech variant at https://gapbench.vibe-eval.com/site/fintech-app/ adds a twist: PATCH on the balance endpoint also works, so you can not only read someone else’s balance but modify it. That is the IDOR-into-financial-impact pivot.
How we detect it
The detection is unglamorous and reliable. We sign up two users. We crawl the app as user A and record every URL that includes an ID-shaped parameter. We then issue the same requests as user B with user A’s IDs substituted. Any 200 response is a finding. PATCH and DELETE require an extra step — we synthesize a benign payload — but the principle is the same.
This is not something you can do with a static scanner. The bug is “the server returns 200 when it should return 403,” and that determination requires a request, a response, and two distinct authenticated sessions. Static scanners can flag suspicious patterns (“this query lacks a where clause that includes the user ID”), but the false-positive rate is high and the signal is weak. Runtime testing is the only authoritative answer, and our vibe-code-scanner runs the BOLA suite as a default check.
Fix
The fix is one of:
-
Database-layer: put the ownership check in an RLS policy. Every query against the row goes through the policy. The check cannot be bypassed by forgetting to include it in application code, because the application code can’t bypass RLS without using the service-role key. If you also rotate the service-role key out of the request path, you’re done.
-
Application-layer: wrap your data access in a helper that takes both the ID and the requesting user, and refuses if the user doesn’t own the resource. Use the helper everywhere. Code-review for any direct
findUniquecall that doesn’t go through the helper. -
Both, ideally. RLS as the floor, helper as the ergonomics layer.
The pattern that doesn’t work: “we’ll remember to add the check on every route.” You won’t. The AI won’t. The intern won’t. The check has to live somewhere structural, not somewhere optional.
CWE / OWASP
- CWE-639 — Authorization Bypass Through User-Controlled Key
- CWE-284 — Improper Access Control
- OWASP API Security Top 10 — API1:2023 Broken Object Level Authorization
Reproduce it yourself
- Multi-tenant SaaS with tenant isolation failure: https://gapbench.vibe-eval.com/site/multi-tenant-saas/
- Fintech IDOR plus balance tampering: https://gapbench.vibe-eval.com/site/fintech-app/
- Indie SaaS with BOLA + secrets + paid bypass: https://gapbench.vibe-eval.com/site/indie-saas/
- Clean RLS reference: https://gapbench.vibe-eval.com/site/ref-rls/
Related reading
- Data study: Broken Object-Level Auth in AI-Generated CRUD
- Pattern: The Supabase service-role key in your frontend bundle
- Tool: vibe-code-scanner
COMMON QUESTIONS
TEST EVERY ROUTE FOR BOLA
We enumerate IDs across roles and tell you which routes returned someone else's data.