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.

  1. Direct CRUD endpoints. /api/invoices/:id returns the invoice. No check that the requester owns it. The simplest case; the most common.

  2. Bulk endpoints. /api/invoices?since=2024-01-01 returns 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.

  3. Aggregate endpoints. /api/dashboard/summary returns 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.

  4. 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.

  5. 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 the owner=request.user part is the bug.
  • Rails: current_user.invoices.find(params[:id]) scopes through the association. AI codegen sometimes writes Invoice.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:

  1. 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.

  2. 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 findUnique call that doesn’t go through the helper.

  3. 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

COMMON QUESTIONS

01
What is BOLA?
BOLA — Broken Object-Level Authorization — means an attacker can change an ID in a request and read or modify another user's data. Authentication is fine; both users are logged in. The vulnerability is that the server returns the resource without verifying the requesting user owns it. It's OWASP API #1, the single most common authorization bug, and the one AI generators reproduce by default.
Q&A
02
Why do AI generators produce BOLA so reliably?
Because the natural shape of a CRUD endpoint is 'find resource by ID and return it.' The ownership check is a separate step that has to be added deliberately. AI codegen pattern-matches on tutorial code where the ownership check is implicit in framework boilerplate, then drops the boilerplate when generating the actual handler. The scaffolding looks complete. The check is missing.
Q&A
03
Does Supabase RLS prevent BOLA?
If your reads go through PostgREST and the policy is correct, yes. The trouble is that AI-built apps frequently add an Edge Function or a Next.js API route layer that uses the service-role key, which bypasses RLS. In those routes the ownership check has to live in application code, and that's where it gets skipped. RLS is necessary but not sufficient.
Q&A
04
Will UUIDs save me?
No. UUIDs make ID enumeration harder but they don't change the authorization model. Once an attacker has a valid UUID — from a public link, a leaked log, a shared URL — they can fetch the resource regardless of ownership. UUID is obscurity. The fix is the ownership check.
Q&A
05
Where can I see this on a real URL?
https://gapbench.vibe-eval.com/site/multi-tenant-saas/ has tenant isolation failure on multiple routes. https://gapbench.vibe-eval.com/site/fintech-app/ shows IDOR + balance tampering. Both let you change an ID in the URL and watch the wrong user's data come back.
Q&A
06
What CWE does this map to?
CWE-639 (Authorization Bypass Through User-Controlled Key), CWE-284 (Improper Access Control). OWASP API #1:2023 (Broken Object Level Authorization).
Q&A

TEST EVERY ROUTE FOR BOLA

We enumerate IDs across roles and tell you which routes returned someone else's data.

RUN BOLA TEST