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.

Unpredictable IDs are not authorizationSwitching from sequential integers to UUIDs makes enumeration harder, but IDs leak constantly: in URLs shared over chat, in exported CSVs, in webhook payloads, in browser history, in support tickets. Treat every identifier as public.

How we test for it

  1. Create at least two tenants, each with users at every role level.
  2. Map every endpoint that accepts an object identifier — in the path, query, body, headers, and nested JSON fields.
  3. For each one, replay requests made by Tenant A using Tenant B's session. Do the same across roles within a single tenant.
  4. Check all verbs. Teams often protect GET and forget PATCH, DELETE, or bulk endpoints.
  5. 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 WHERE clause 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.

All research Next: SSRF in the Cloud: Why the Metadata Service Still Matters
Start a conversation

Let's discuss your security needs.

Tell us what you're building. We'll respond with clear questions, an honest view of whether we're the right fit, and a scoped proposal if we are.