The API Vulnerability Scanners Cannot Find: Broken Object Authorization

Table of Contents
- The Simplest Serious Vulnerability
- Why Scanners Miss It
- Authentication Passed, Authorisation Absent
- The Variants Worth Knowing
- Why UUIDs Do Not Fix It
- Enforcement at the Data Layer
- Testing for It Systematically
- Mass Assignment and Over-Exposure
- Common Pitfalls
- Conclusion
- Frequently Asked Questions
Key takeaway: Every endpoint accepting an identifier must verify that the authenticated caller is entitled to that specific object. Verifying only that the caller is authenticated is the vulnerability, and it is invisible to automated scanning because both requests look valid.
The Simplest Serious Vulnerability
GET /api/invoices/4821
Authorization: Bearer <valid token for user A>
200 OK — user A's invoice. Correct.
GET /api/invoices/4822
Authorization: Bearer <valid token for user A>
200 OK — user B's invoice. Data breach.
That is the entire exploit. Change a number. No tooling, no payload crafting, no timing attack. The request is syntactically identical to a legitimate one and differs only in whose data it returns.
This class of flaw — broken object level authorisation, sometimes called insecure direct object reference — consistently ranks as the most prevalent serious API vulnerability. It appears in mature applications built by competent teams, and it appears repeatedly rather than once.
The reason it is so common is structural. Every endpoint that accepts an object identifier requires an authorisation check, each check must be written individually, and omitting one produces no error, no warning, and no visible symptom. It works perfectly for legitimate users. The flaw is only observable if someone deliberately requests an object they should not have.
Why Scanners Miss It
Automated security tooling detects this class poorly, and understanding why explains the persistent gap between clean scan reports and exploitable applications.
Both requests are valid. A scanner sees a well-formed authenticated request returning a 200 response. Nothing in the traffic indicates the response contained the wrong person’s data. There is no error, no anomalous status code, no injected payload.
Determining correct ownership requires domain knowledge. Whether user A should access invoice 4822 depends on your business rules — perhaps they are the same organisation, perhaps A is an administrator, perhaps there is a delegation relationship. A scanner cannot know your entitlement model.
Multiple identities are needed to test. Detecting the flaw requires authenticating as two different users and confirming that each cannot reach the other’s objects. Most scanning configurations use one credential set.
Enumeration looks like normal traffic. Iterating through identifiers with a valid token resembles a user browsing their records.
The consequence is that this vulnerability class is found by humans who understand the application’s authorisation model, or by tests written by those humans, or not at all. A clean automated scan provides no assurance here.
Authentication Passed, Authorisation Absent
The root cause is conflating two questions that developers routinely treat as one.
Middleware validates the token and populates a user object. From that point, the request is “authenticated,” and it is easy to treat that as sufficient. The handler receives a validated identity and an object identifier, and fetches the object.
# Vulnerable: authentication verified, entitlement never checked
@app.get("/api/invoices/{invoice_id}")
@require_auth
def get_invoice(invoice_id: int, user: User):
return db.invoices.find_by_id(invoice_id)
# Correct: the query is scoped to what this user may see
@app.get("/api/invoices/{invoice_id}")
@require_auth
def get_invoice(invoice_id: int, user: User):
invoice = db.invoices.find_one(
id=invoice_id,
organisation_id=user.organisation_id, # entitlement in the query
)
if invoice is None:
raise NotFound() # not Forbidden — see below
return invoice
Two details in the corrected version matter beyond the obvious.
The entitlement condition is part of the database query rather than a check after retrieval. A check performed after fetching can be forgotten, and worse, a fetch-then-check pattern in one place and a fetch-only pattern in another is exactly how inconsistency arises. Scoping the query makes the unauthorised object unreachable rather than retrieved-then-rejected.
Returning 404 rather than 403 avoids confirming that the object exists. A 403 tells an enumerating attacker that invoice 4822 is real and belongs to someone else, which is information. A 404 is indistinguishable from a nonexistent identifier.
The Variants Worth Knowing
The same underlying failure appears in several forms, and teams that fix the obvious one frequently retain the others.
Broken object level authorisation. Accessing another user’s object by identifier. The canonical form.
Broken function level authorisation. Calling an administrative endpoint as an ordinary user. Frequently the administrative interface is a separate frontend that hides the buttons while the endpoints remain reachable.
Broken object property authorisation. Correct object, but the response includes fields the caller should not see, or the caller can modify fields they should not — internal notes, pricing, role assignments.
Authorisation in nested resources. /api/organisations/12/invoices/4822 may check organisation 12 access while never confirming that invoice 4822 belongs to organisation 12. The parent check creates a false sense of coverage.
Batch and bulk endpoints. An endpoint accepting an array of identifiers where each element requires an individual check. Checking the first and assuming the rest is a common shortcut.
Search and filter endpoints. A search that queries across all records and filters results afterwards can leak through result counts, aggregations, or sort behaviour even when individual records are excluded.
That last one is subtle and worth watching for. An endpoint returning “12 results” from a filtered set of 3 has disclosed something about the 9 it withheld.
Why UUIDs Do Not Fix It
A common response to sequential identifier enumeration is switching to random identifiers. This helps and does not fix the vulnerability.
What it does: makes blind enumeration impractical, since guessing a random 128-bit identifier is infeasible.
What it does not do: prevent access when the identifier is known. And identifiers leak constantly — in URLs shared between users, in email notifications, in exported reports, in webhook payloads, in browser history, in referrer headers, in support tickets, in log files accessible to more people than the data itself.
More importantly, a former employee, a departed contractor, or a user removed from an organisation retains any identifiers they saw. Random identifiers offer no protection against someone who legitimately obtained them and subsequently lost entitlement.
Random identifiers are worth using — they eliminate casual enumeration and are a reasonable defence in depth. Treating them as the fix substitutes obscurity for authorisation.
Enforcement at the Data Layer
Per-endpoint checks fail because they rely on every developer remembering every time. Structural enforcement is more reliable.
Scope every query by tenant or owner. Make it architecturally difficult to write a query that is not scoped. Some teams achieve this by requiring all data access to pass through a repository layer that injects the scope automatically.
Row-level security in the database. Where supported, policies enforced by the database mean an unscoped application query still cannot return unauthorised rows. This is the strongest available form, because it holds even when application code is wrong.
A centralised authorisation service. One place implementing the entitlement rules, called by every handler. Consistency by construction rather than by discipline.
A default-deny framework pattern. Endpoints that do not explicitly declare their authorisation requirement are rejected rather than permitted. This converts omission from a silent vulnerability into a loud failure, which is the single most valuable structural change available.
That final pattern deserves emphasis. The vulnerability exists because forgetting a check produces working code. A framework where forgetting produces a 500 error at development time eliminates the entire class, and it is achievable with modest framework work.
Testing for It Systematically
Because tooling does not find this reliably, testing must be deliberate.
Two-identity integration tests. For every endpoint accepting an identifier, a test that authenticates as user A and attempts to access user B’s object, asserting a 404. This is mechanical, and it can be generated from your route table rather than written by hand for each endpoint.
Test the full method set. An endpoint may be correctly scoped for GET and unscoped for PATCH or DELETE. Each method needs its own test.
Include nested paths. Verify that child objects genuinely belong to the parent in the path.
Test batch endpoints with mixed arrays. Some identifiers the caller owns, some they do not. Assert that the entire request fails rather than partially succeeding.
Test property-level exposure. Assert that responses contain only the fields this role should see, and that requests cannot modify fields this role should not set.
Make it a review requirement. Any new endpoint accepting an identifier requires the corresponding authorisation test before merge.
An adversarial code review is also effective and cheap: read every data access call and ask what constrains it to this caller’s data. Calls where the answer is “the handler checks earlier” deserve scrutiny, because that check is the thing that goes missing.
Mass Assignment and Over-Exposure
Two closely related flaws that share the same root — trusting the client’s view of the object.
Mass assignment. An endpoint that binds request fields directly to a model lets a caller set fields they should not. Sending {"name": "x", "role": "admin"} to a profile update endpoint that accepts the whole body is privilege escalation. The fix is an explicit allowlist of writable fields per role, never a blocklist and never automatic binding.
Excessive data exposure. Returning the full object and relying on the client to display a subset. The API response contains everything, and anyone reading the network traffic sees it. Internal identifiers, audit fields, other users’ details in embedded objects, and password hashes have all been exposed this way. The fix is explicit response serialisation per endpoint and role rather than returning the model.
Both are mechanically similar to the authorisation problem: the safe version requires explicitly declaring what is permitted, and the convenient version — bind everything, return everything — is the vulnerable one.
Common Pitfalls
Authentication middleware treated as authorisation. Verifying who is calling is not verifying what they may reach.
Checking after fetching instead of scoping the query. Fetch-then-check patterns get forgotten and applied inconsistently.
403 instead of 404 for unauthorised objects. Confirms existence to an enumerating attacker.
Relying on random identifiers. They leak, and they do not help when entitlement is revoked.
Checking the parent in nested paths only. Verify the child belongs to the parent.
Automatic model binding on write endpoints. Enables privilege escalation through unexpected fields.
Trusting the client to filter responses. Anyone can read the raw response.
Conclusion
Broken object authorisation is the most common serious API vulnerability because it is the easiest to introduce and the hardest to notice. Omitting the check produces code that works correctly for every legitimate user, passes automated scanning, and returns other people’s data to anyone who changes an identifier.
The reliable defences are structural rather than attentional. Scope every query by owner or tenant so the unauthorised object is unreachable rather than retrieved and rejected. Use row-level security where the database supports it, so application errors do not become data exposure. Adopt a default-deny framework pattern where an endpoint without a declared authorisation rule fails loudly at development time.
Then test with two identities, mechanically, across every endpoint and every method. That test suite is the only thing that reliably catches the check somebody forgot.
Frequently Asked Questions
Do scanners really not find this? Not reliably. Detection requires knowing your entitlement model and testing with multiple identities. Some tools support configured multi-user testing and coverage remains partial. Treat a clean scan as no evidence either way.
Should unauthorised requests return 403 or 404? 404 for objects the caller may not access, which avoids confirming existence. 403 is appropriate when the caller may know the object exists but lacks permission for the specific operation.
Are random identifiers worth adopting? Yes, as defence in depth — they stop casual enumeration. They are not a substitute for authorisation, because identifiers leak and entitlement changes.
How is this handled in GraphQL? The same problem with a larger surface, since clients traverse relationships freely. Authorisation must be enforced per field and per resolver, and nested traversal makes omissions easier to introduce.
Is row-level security worth the complexity? For multi-tenant applications handling sensitive data, generally yes. It provides enforcement that survives application bugs, which no application-layer check does.
What about internal service-to-service APIs? They need authorisation too. An internal service that trusts any caller becomes a full data access path once any other service is compromised, which is the standard lateral movement route in service architectures.
How can existing endpoints be audited efficiently? Enumerate every route accepting an identifier, then read the data access call for each and identify what constrains it to the caller. Generate the two-identity tests from that same route list. The exercise is tedious and finds real vulnerabilities almost every time.



