BOLA: The API Bug Your Scanner Will Never Find
Broken object-level authorization tops the OWASP API Security Top 10 for a reason. It is invisible to tools that do not understand who should own what.
Published by Xevry
Broken Object Level Authorization — BOLA, historically called IDOR — is ranked first in the OWASP API Security Top 10. It is also one of the simplest bugs to describe: the API verifies who you are, but not whether you are allowed to access this particular object.
What it looks like
app.get("/api/invoices/:id", requireAuth, async (req, res) => { const invoice = await db.invoice.findUnique({ where: { id: req.params.id }, }); if (!invoice) return res.sendStatus(404); res.json(invoice);});requireAuth confirms the caller has a valid session. Nothing confirms the invoice belongs to the caller's organization. Any authenticated user — including a free-trial account created a minute ago — can read any invoice by changing the ID.
Why automated scanners miss it
To a scanner, a request for invoice A returning 200 with valid JSON looks identical whether or not the caller should see invoice A. Detecting BOLA requires knowing the intended ownership model, which lives in the business logic and in people's heads, not in the HTTP traffic.
How we test for it
- Create at least two tenants, each with users at every role level.
- Map every endpoint that accepts an object identifier — in the path, query, body, headers, and nested JSON fields.
- For each one, replay requests made by Tenant A using Tenant B's session. Do the same across roles within a single tenant.
- Check all verbs. Teams often protect
GETand forgetPATCH,DELETE, or bulk endpoints. - Check indirect references: an export job, a file download URL, or a GraphQL field resolver that loads a related object by ID.
The result is an authorization matrix — every endpoint against every role and tenant — with the expected and observed outcome for each cell.
Fixing it structurally
The immediate fix is to scope the query by owner:
const invoice = await db.invoice.findFirst({ where: { id: req.params.id, orgId: req.user.orgId },});But fixing endpoints one at a time does not scale. The durable fixes move authorization out of individual handlers:
- Scoped data access by default. A repository layer that requires a tenant context for every query, so an unscoped lookup is a type error rather than a code review catch.
- Database-enforced isolation. PostgreSQL row-level security policies keyed on a session variable mean that even a forgotten
WHEREclause cannot cross tenants. - A central policy function.
can(user, action, resource)evaluated in one place, with tests that enumerate the matrix above. - Return 404, not 403, for objects the caller cannot see, to avoid confirming that an ID exists.
Takeaways
BOLA is not a sophisticated bug. It persists because it is easy to write, impossible to detect without context, and invisible in normal usage. The defense is equally unglamorous: make ownership checks structural, then test them exhaustively with real accounts.