We have run more than 500 VAPT engagements since 2015. There is one bug class we find in more than 60% of API engagements: Broken Object Level Authorization — BOLA, also known as Insecure Direct Object Reference (IDOR). Here is what it looks like, why ORMs make it worse, and the three patches that actually fix it.
The bug at the request layer
Request as user 8124 (their own invoice)
GET /api/v2/invoices/8124
Authorization: Bearer eyJ...
→ HTTP 200, {"id": 8124, "amount": 4500, "vendor": "Mine"}
Same user, different ID
GET /api/v2/invoices/8125
Authorization: Bearer eyJ...
→ HTTP 200, {"id": 8125, "amount": 99000, "vendor": "Not mine"} ← bug
The server checked authentication. It did not check authorization for this specific object. The token is valid, so the data comes back.
Why it happens
The mental model is: "User is logged in → user can see invoice." The correct mental model is: "User is logged in AND user owns invoice → user can see invoice." Three architectural patterns make the wrong model the default:
- ORMs make "fetch by ID" look identical regardless of caller.
Invoice.objects.get(id=8125)doesn't know who is asking. The check has to be added at every call site. - REST URLs use object IDs.
/invoices/8125is right there in the URL — that surfaces the bug, but the underlying issue is that the auth layer doesn't see the URL parameter. - JWTs carry only the user, not their owned-objects. The token says "user 8124" but not "user 8124's invoices are: 8124, 8133, 8201". The check has to hit the database.
The three patches that actually work
Patch 1 — Filter by ownership in every query
The pragmatic fix. Every SELECT includes WHERE owner_id = :current_user. The endpoint code looks like:
invoice = Invoice.query.filter_by(id=invoice_id, owner_id=current_user.id).first()
if invoice is None: return 404
Pro: works with any framework. Con: depends on every developer remembering. Fragile under feature growth.
Patch 2 — Row-level security in the database
PostgreSQL, MySQL 8, SQL Server all support row-level security policies. Define a policy: USING (owner_id = current_setting('app.user_id')). Every query, regardless of where in the code it comes from, is automatically filtered by the database.
Pro: impossible to bypass at the application layer. Con: requires the application to set the user context per request — a one-time middleware change.
Patch 3 — Capability tokens / unguessable identifiers
Replace id=8125 in URLs with a per-object capability token — 128 bits of random, scoped to the user. Old URLs are not enumerable; even a leaked token is single-purpose.
Pro: eliminates the enumeration surface entirely. Con: URLs become opaque, which hurts debugging and analytics. Requires a token-to-object map.
Patches that do NOT work
- UUIDs instead of integers. "Unguessable" is not "unauthorisable". If a UUID leaks (referrer header, support ticket, browser history), the bug is back.
- Encrypting the ID in transit. Same bug, different ID encoding.
- Adding the user ID in the URL.
/users/8124/invoices/8125looks safer, but if the server doesn't compare URL:user_idto the JWT subject, the bug is unchanged. - Rate-limiting enumeration. A rate limit means a slow attack, not a blocked one. And the bug is real even at one request.
How to test for it on your own API
- Create two test users in two different tenants. Call them A and B.
- As user A, list every resource you can read or modify. Note the IDs.
- As user B, with B's auth token, attempt to GET, PATCH, DELETE every ID from user A's list.
- Any HTTP 200, 201, 204 is a bug. Even partial data (a name field leaking from a 403 response) is a bug.
- Repeat for nested resources —
/projects/X/files/Y— every level needs its own check.
A small Python harness running this systematically against a 30-endpoint API is roughly 200 lines of code and runs in five minutes. Worth writing once and running on every release.
SAST tools see the SQL query but cannot tell whether filter_by(id=X) is missing the ownership predicate — they have no concept of ownership. DAST tools see the request but do not have the second user to swap IDs with. BOLA is a bug class that requires human testing or a custom test harness. There is no off-the-shelf tool that finds it for you.
For an API engagement, see API security testing. For the broader OWASP API context, see the OWASP API Top 10 guide.
Need a VAPT engagement scoped against this?
Tell us the asset and the compliance overlay. We will come back with a scope, timeline, and fixed-fee quote within 24 hours. Engagements start at USD 500. Free retest included.
Book a 20-minute call →BERRY9 IT SERVICES — VAPT Practice
Hyderabad-based ISO 27001 + 9001 certified offensive-security team. Since 2015 we have run 500+ engagements for 100+ clients across pharma, BFSI, healthcare, VFX, and enterprise SaaS. Every engagement includes a free retest.