The authorisation bug you will ship next quarter
Broken object-level authorisation is the finding we report most often, and it is almost never a framework failure. It is a tenancy assumption made once, early, and then trusted everywhere downstream.
Every multi-tenant system makes a decision early about where tenancy lives. Sometimes it is a column on every table, sometimes a schema per customer, sometimes a claim in a token. The decision itself is rarely the problem. The problem is that it gets made once, by one person, in week three — and then every endpoint written afterwards assumes it is being enforced somewhere else.
By the time we test the system, there are two hundred endpoints and no single place that answers the question “may this caller touch this object?” Most of them are fine. Four are not, and they are the ones written under deadline pressure by whoever was on call.
This is the single most common serious finding we report. It is not exotic, it does not require a novel technique, and it is almost never the framework’s fault. Rails, Django, Spring and Express will all happily enforce authorisation for you if you ask them to. The bug is that nobody asked, on four endpoints out of two hundred, and nothing in the build told anyone.
Authentication is not authorisation
The distinction sounds pedantic until you see what it costs. Authentication answers “who is this?” Authorisation answers “may this person do this, to this object?”
Almost every team gets the first one right. It is centralised by necessity — there is one login flow, one session mechanism, one token format, and it is exercised by every single request, so a mistake surfaces immediately. Authentication bugs are loud.
Authorisation is the opposite. It is decided per endpoint, per object, per action. It is exercised only by the specific request that touches that specific object, which in practice means it is exercised by exactly one caller: the one who owns the data. Your own users will never trip the check, because their requests are legitimate. A missing authorisation check is silent, by construction, until someone deliberately goes looking.
That asymmetry is the whole story. The reason this class of bug survives to production is not that it is hard to fix. It is that nothing in normal operation produces a signal.
Where we look first
Anything that takes an identifier from the client and uses it to fetch a record. That is the whole heuristic, and it finds most of them.
In practice the candidates cluster in a few predictable places:
- Export and reporting endpoints. Written late, often by someone outside the main team, frequently bypassing the ORM scopes the rest of the app relies on because they needed a faster query.
- Bulk operations. The single-object endpoint checks ownership correctly. The
POST /items/bulk-updatewritten six months later iterates a list of IDs and checks nothing. - Admin panels bolted on later. Built for internal use behind a VPN, then quietly exposed when someone needed access from home. The authorisation model was “you are on the internal network,” and that model left the building.
- Anything with a sequential integer ID. Not because integers are insecure — a UUID with no check is exactly as broken — but because guessable identifiers turn “possible” into “trivial to enumerate”, and they make the finding easy to demonstrate.
- Second-order references. The endpoint checks that you own the invoice. It does not check that the
customer_idyou passed in the body belongs to the same tenant as the invoice.
If an identifier arrives from the client and is used without re-deriving scope from the session, assume it is exploitable until you have a test proving otherwise.
The last category is the one teams miss most reliably, because the endpoint does have an authorisation check. It just checks the wrong object.
The three-request path
Here is what the finding looks like when we write it up. It is not a clever chain. That is the point.
POST /api/signup
{ "email": "[email protected]", "org": "Example Ltd" }
→ 201 Created
session established, org_id = 4821
GET /api/documents/90114
Cookie: session=...
→ 200 OK
{ "id": 90114, "org_id": 4821, "title": "Q3 plan", ... }
PATCH /api/documents/90114
Cookie: session=...
{ "org_id": 1002 }
→ 200 OK
The document now belongs to another tenant’s organisation, or — depending on which side of the check the value is read — the session now reads as though it belongs to one. Either way the boundary is gone, and it took three requests and no tooling beyond the browser’s own network tab.
We report this as critical, and the severity conversation is usually short. The exploitability is not in question, because we did it.
What this costs to ignore
One authorisation gap in a multi-tenant application is not one leaked record. It is every record the application can reach, available to anyone who can sign up.
This is where the risk model differs from most other bug classes. A SQL injection needs a payload that survives your escaping. A deserialisation bug needs a gadget chain. An authorisation gap needs a valid account and a loop. If your product has self-service signup, the attacker’s cost of entry is an email address.
It is also the class regulators and customers understand immediately. “We had a vulnerability in a third-party library” is a conversation. “Any customer could read every other customer’s data by changing a number” is a notification obligation, and it reads the same way in a breach disclosure as it does in a headline.
And it is durable. A missing check does not get fixed incidentally by a dependency bump or a platform upgrade. It sits there through every release until someone specifically looks for it.
The fix is structural, and boring
The fix is not a library. It is a decision about where scope comes from, enforced in a place the next engineer cannot route around.
Derive scope server-side, from the session, always. The tenant identifier should never be read from a request body, a query parameter, a path segment, or a header the client controls. If the caller can write it, it is an input, not an identity.
Make the scoped query the default and the unscoped query the exception. A default scope on the model, a repository that requires a tenant argument, a query helper that cannot be called without one — whichever mechanism fits your stack, the property you want is that writing the insecure version takes more effort than writing the secure one. Guardrails work when the safe path is also the easy path; they fail when they depend on everyone remembering.
Add one authorisation test per endpoint. Not per feature — per endpoint. The test is always the same shape: authenticate as tenant A, request tenant B’s object, assert 403 or 404. It is mechanical enough to generate from your route table, and it is the only thing that stops the next person from regressing it silently.
Fail closed on unknown routes. A default-deny authorisation layer means a new endpoint is inaccessible until someone declares its policy. That converts “forgot to add a check” from a silent vulnerability into a loud failure in staging.
Boring fixes are the ones that survive a year of feature work. A clever fix that lives in one developer’s head does not survive their next job change.
How to check this yourself, this week
You do not need us for the first pass, and you should not wait for a scheduled engagement to find out.
- Two accounts, two tenants. Create them in staging with realistic data on both sides.
- Walk the route table, not the UI. The UI only exercises the paths the UI knows about. Export the route list from your framework and work through it.
- For every endpoint that accepts an identifier, swap it. Use tenant A’s session with tenant B’s object ID. Anything that is not a 403 or a 404 is a finding.
- Then swap the identifiers in the body, not just the path. This is the step that catches the second-order references, and it is the step almost everyone skips.
- Write down every 200 you get. Do not triage while you are testing; you will talk yourself out of real findings. Collect first, rank after.
An afternoon of this on a mid-sized application will usually turn up something. If it turns up nothing, that is genuinely good news and worth knowing.
If you want to know whether your system has this, the fastest answer is not a scanner. Scanners cannot tell the difference between a document you are allowed to read and one you are not, because that distinction lives entirely in your business logic and nowhere in the HTTP response. It is one engineer with a valid account and two hours.