An object reference is not an authorization token
There is a mistake so ordinary that it barely reads as a bug when you find it in a diff. The client hands you an identifier. Your code looks up the object that identifier names. Your code returns it.
The step nobody wrote is the one that asks whether this caller was ever allowed to see that object. Read-side, that omission is an insecure direct object reference. Write-side, it is mass assignment. Same root, two harvests.
Three incidents
First American Financial: 885 million documents, one URL parameter (2019)
First American Financial is a US title insurance company. Its document-viewing web application generated links containing a sequential numeric record ID, and the server returned the document without authenticating the requester or checking whether that requester had any relationship to the record.
Increment the number. Get somebody else's file. Decrement it. Get another one.
Krebs on Security reported that roughly 885 million document images were reachable this way, going back to 2003, including Social Security numbers, bank-account numbers, mortgage and tax records, wire-transfer receipts, and driver's licence images. The SEC settled with the company for $487,616 in 2021, its first enforcement action over cybersecurity disclosure controls, and New York's Department of Financial Services settled for $1,000,000 in 2023.
An important qualification on that headline number: 885 million is the count of files that were publicly reachable without a login. It is not a count of records proven to have been taken by criminals. Those are different claims and only the first one is established.
Optus: an API that needed no password and counted upwards (2022)
The Australian telecommunications company Optus exposed a customer API to the
internet with no authentication at all, keyed on a sequential contactID, and
with no rate limiting to slow anybody working through the range.
Two failures stacked. Broken authentication, because the endpoint required no credentials. Broken object-level authorization, because even with credentials there was no ownership check on the object being requested. Either alone is serious. Together they turn an API into a bulk export endpoint operated by anyone with a loop.
The figures need care. The OAIC's civil penalty proceedings put the number of affected customers at about 9.5 million; early reporting said 9.8 million. Roughly 2.1 million had government identity-document numbers, such as driver's licence or passport numbers, exposed. Optus set aside A$140 million for remediation. If you see a figure of around US$11 million attached to this incident, it is not credible; the company's own provision is the number to use.
GitHub and Rails: one hidden form field (2012)
The write-side version, and the cleanest demonstration of the class anyone has managed.
Ruby on Rails at the time bound every parameter in an HTTP request body
directly onto the model being updated, unless the developer had explicitly
listed the permitted attributes with attr_accessible. GitHub's public-key
form had not listed them.
Egor Homakov added one extra parameter to the POST body:
public_key[user_id] set to another user's ID. Rails mass-assigned the foreign
key. His SSH key was now attached to somebody else's account, and that somebody
else was a Rails core maintainer, which gave him push access to the rails/rails
repository. He pushed a harmless commit to prove it.
His write-up is still online. GitHub patched within hours and briefly suspended, then reinstated, him. There was no data theft and no financial loss, and any dollar figure you see attached to this incident is invented. Its significance is entirely didactic: it demonstrated the vulnerability class on the most prominent Rails application in existence, and the industry response made attribute allowlisting (strong parameters) the framework default.
Why this compiles, ships, and passes review
The bug is an absence. There is no wrong line to spot. Document.find(id)
is correct code. user.update(params) is correct code. What is missing is a
line that was never written, and no reviewer has ever been good at noticing the
absence of a statement in a file they are reading for the first time.
The tests pass, because the tests are the owner. Almost every functional test authenticates as the user who owns the data and then asks for that data. That test exercises the happy path perfectly and can never fail on a missing ownership check. The test that catches this is the one where user A requests user B's object and expects a 404, and it is rarely written unless somebody has been bitten before.
The framework is doing what you asked. Mass assignment is a feature: it
exists so you do not have to write forty lines of field copying. An ORM's
find is a feature. Neither has any way to know which fields a stranger should
be allowed to set, or which rows a given session should be allowed to read.
Unguessable IDs create the illusion of a check. Teams switch from sequential integers to UUIDs and consider the problem handled. It is not. A UUID is harder to enumerate blindly, but object IDs leak constantly: through URLs shared in tickets, through exports, through other API responses, through a former employee's browser history, through the invoice PDF you emailed. Unguessable identifiers are defence in depth. They are not the control.
It scales with the codebase, not with the feature. Every new endpoint is a new opportunity to forget. A team that gets it right in fifty places and wrong in the fifty-first has the same exposure as a team that never tried.
The class of flaw
This is broken access control, which OWASP ranks as A01:2021, the top category in the current list. API practitioners split it further: broken object-level authorization for the read-side case, and broken function level authorization for the "I called the admin endpoint and it worked" case.
The unifying statement is short enough to put on a wall: an identifier supplied by the caller describes which object, and it says nothing whatsoever about whether. Any code that treats the presence of a valid ID as evidence of entitlement has confused a name with a permission.
The write side is the same confusion pointed the other way. A field name in a
request body describes which attribute to set, and says nothing about whether
this caller may set that attribute. user_id, role, is_admin,
account_balance, organization_id, verified_at: every one of those is a
perfectly normal column, and every one of them is a privilege escalation if a
client can write to it.
What to check in your own codebase
Scope the query, do not filter afterwards. Prefer
SELECT ... WHERE id = ? AND owner_id = ? over fetching by ID and comparing
ownership in application code. The two are equivalent when the comparison is
present, but only the first makes the check impossible to omit, and only the
first survives someone refactoring the handler later.
Put the check somewhere a missing call is visible. A per-request authorization layer, a policy object, a repository that only ever accepts a caller context: any of these turn "forgot to check" from an invisible absence into a compile error, a lint failure, or at minimum a conspicuous deviation from the pattern in every other file.
Write the second-user test. For each resource, one test where user A requests user B's object and expects a denial. This single habit catches more real access-control bugs than any tool, and it is cheap because it is the same test body with a different fixture.
Allowlist writable fields at every write path. Strong parameters, DTOs, explicit field mapping, a serializer with an explicit set. Never bind a request body straight onto a persisted model. Then audit the allowlists specifically for foreign keys and state fields, which are the ones that grant something.
Enumerate the endpoints that return collections or exports. List endpoints,
search endpoints, report and CSV download endpoints, and anything with an
?include= parameter tend to be written after the detail endpoint and to reuse
none of its checks. They are also the highest-value targets, because one
missing check returns everything at once.
Grep for the endpoints that predate your authorization layer. Every codebase has routes older than its current access-control pattern. They are usually internal tools, admin panels, legacy mobile endpoints, and webhook receivers, and they are usually not covered by the middleware everyone assumes covers everything.
Rate limiting is damage control, not access control. It changes how long a full extraction takes. It does not make the first unauthorized response authorized.
Log denied requests and alert on the shape. A single 403 is noise. Four hundred sequential 403s from one session is an attacker enumerating, and it is one of the few access-control signals that is genuinely easy to detect at runtime.
Where the record is thin
For First American, 885 million counts files reachable without authentication, not files exfiltrated. We have not seen a credible figure for the latter, and neither settlement establishes one.
For Optus, the customer count varies between the regulator's figure and early reporting, and we have cited both rather than picking the larger.
For the GitHub incident, there is no damage figure because there was no damage. It is included because it is the clearest available demonstration of the write side of the class, performed on a live system by someone who then explained exactly what he did.
Sources
- Krebs on Security: First American Financial Corp. leaked hundreds of millions of title insurance records
- SEC: charges against First American over disclosure controls
- Bank Info Security: First American settles with the SEC
- OAIC: civil penalty action against Optus
- API Security: the Optus breach and authentication versus authorization
- Egor Homakov: how to hack GitHub in 20 minutes
- The Register: GitHub mass-assignment hack
Where Vulkro fits
Access control is the class where automated analysis is weakest, because the rule being broken lives in your domain model rather than in any syntax. It is worth knowing what a scanner can and cannot see before you rely on one. Vulkro reports the read side by inventorying routes that take a caller-supplied identifier with no ownership predicate in the query or the handler, and the write side by finding unbounded body-to-model binds. Neither can tell you what your domain rules are, so both are a work queue rather than a verdict.
Read next:
- API1 Broken Object Level Authorization: the rule page for the read-side case.
- API3 Broken Object Property Level Authorization: the rule page for mass assignment.
- Supported languages and frameworks: what Vulkro reads, and where it stops.
- What Vulkro does: the product overview.
- Static analysis with no network: running the scanner on a disconnected machine.