BOLA — the bug scanners can't find
Broken Object Level Authorization tops the OWASP API Security Top 10 because it's a business-logic bug. How I hunt it with two accounts and a proxy, and how I design APIs so it can't happen.
If I could pick one vulnerability class to explain to every backend developer, it would be Broken Object Level Authorization (BOLA) — the API-world name for IDOR (Insecure Direct Object Reference). It is API1 in the OWASP API Security Top 10 (2023 edition), and in my experience it's the finding that most often moves an assessment from "medium" to "critical".
What it looks like
GET /api/v1/invoices/48213
Authorization: Bearer <token for user A>
The API checks that the token is valid (authentication) but never checks that invoice 48213 belongs to user A (authorization). Change the ID to 48214 and you're reading someone else's invoice. Swap GET for PUT or DELETE and you're changing it.
The pattern hides in many forms:
- IDs in the path, query string, JSON body, headers or GraphQL arguments
- Nested resources:
/orgs/{orgId}/projects/{projectId}where onlyorgIdis checked - Bulk endpoints:
POST /export {"ids":[...]} - Indirect references: a file key, an S3 object path or a "share token" that's guessable
Why scanners miss it
A scanner sees 200 OK for both requests. It has no idea that invoice 48214 shouldn't be visible to user A, because that's a business rule, not a technical signature. BOLA needs a tester who understands the data model — which is exactly why it survives automated scanning and shows up in manual VAPT.
How I test for it
- Two accounts minimum, ideally in two tenants. User A creates resources; I note every object ID that comes back.
- Replay A's requests with B's session. Burp's Autorize extension does this automatically for every request as you browse, and highlights responses that look the same.
- Test every verb. Read access is often checked while update and delete are not, or the other way round.
- Don't trust UUIDs. Unguessable IDs reduce discoverability but they leak constantly — in URLs, emails, logs, other API responses and exported CSVs. Obscurity isn't authorization.
- Look for function-level gaps too (API5: Broken Function Level Authorization): can a normal user call
/admin/*, or change"role"in a profile update (mass assignment)?
How I prevent it as a developer
The fix is architectural: make authorization the default path, not something each handler remembers to do.
Scope queries by the caller, not just the ID:
-- ❌
SELECT * FROM invoices WHERE id = $1;
-- ✅
SELECT * FROM invoices WHERE id = $1 AND tenant_id = $2;
Push the check into the data-access layer so a handler physically can't fetch an object without the caller's context:
func (r *InvoiceRepo) Get(ctx context.Context, actor Actor, id string) (*Invoice, error) {
return r.q.GetInvoiceForTenant(ctx, id, actor.TenantID) // no unscoped method exists
}
Defence in depth:
- Row-level security in PostgreSQL (
CREATE POLICY … USING (tenant_id = current_setting('app.tenant_id')::uuid)) as a database-level safety net. - A central policy engine (OPA, Cedar, or a well-tested in-house module) for anything beyond simple ownership.
- Return 404, not 403, for objects outside the caller's scope, so the API doesn't confirm they exist.
- Authorization tests in CI: for every endpoint, a test that user B gets 404 on user A's object. These are cheap to write and catch regressions forever.
The takeaway
BOLA is a design bug, so it needs a design fix. As a tester I find it by thinking about who should see each object; as a developer I prevent it by making "fetch this object for this caller" the only way to fetch anything.
References
- OWASP — API1:2023 Broken Object Level Authorization
- OWASP — Authorization Cheat Sheet
- PostgreSQL Docs — Row Security Policies