IDOR is still everywhere, and here is why
Broken object level authorisation stays the most common serious finding in web applications. Not because it is hard to fix, but because of where the check has to live.
Insecure direct object reference — or broken object level authorisation, which is the more precise name — is the finding I have run into most often. It is also the one that is easiest to explain and hardest to eliminate, and the reason for that is structural rather than technical.
The shape of the bug
An endpoint accepts an identifier and returns the object it names:
GET /api/invoices/8431 HTTP/1.1
Authorization: Bearer <valid token for user A>The server checks that the token is valid. It does not check that invoice 8431 belongs to user A. Change the number, get someone else's invoice.
That is the whole vulnerability. Every variation is the same mistake wearing a different hat.
Why it keeps happening
Authentication is centralised. It lives in a middleware, it runs on every request, and if it breaks, everything breaks loudly and immediately.
Authorisation is not centralised. It has to be evaluated per object, per action, and it depends on business rules that live in the application's head. There is no single place to put it, which means there are hundreds of places to forget it.
Add the ordinary pressures of software delivery — a new endpoint added under deadline, a field exposed to unblock the mobile team, a bulk operation written by someone who did not know the single-object version had a check in it — and the gaps appear faster than the reviews close them.
Where to look
The obvious places are exhausted first, so they are also the ones already fixed. The productive ones:
Secondary identifiers. The primary id on the main route is usually checked. The project_id in a filter parameter, the owner_id in a PATCH body, the team field in an export request — much less often.
Non-GET verbs on the same resource. I have lost count of applications where GET /api/documents/{id} is correctly scoped and DELETE /api/documents/{id} is not. Different handler, different author, different day.
Bulk and batch endpoints. POST /api/items/bulk-update taking an array of ids is one loop away from checking the first element and trusting the rest.
Anything that produces a file. Export, report and download endpoints tend to be written as an afterthought and frequently bypass the ORM layer where the scoping lived.
Nested resources. /api/teams/{team}/members/{member} — the team is checked, the member is looked up globally, and now any team can address any member.
Testing it properly
Two accounts in the same role. Not an admin and a user, which tests something different — two peers who should not see each other's data.
Then, for every request the application makes, replay it with the other session and record what happens. The distinction that matters is between a 403 and a 404: a 403 means the object was found and the check said no; a 404 for an object you know exists means the lookup itself was scoped, which is the stronger design.
# Replaying a captured session against a peer's object ids.
# Only ever against my own lab or an authorised scope.
ffuf -u https://target/api/invoices/FUZZ \
-w ids.txt \
-H "Authorization: Bearer $PEER_TOKEN" \
-mc 200 -fs 0The fix that actually holds
Not "add a check to this endpoint". That fixes one instance and leaves the mechanism intact.
The design that holds is scoping the lookup itself, so an unauthorised object is never retrieved in the first place:
// Fragile: fetch, then judge. One forgotten guard and it leaks.
const invoice = await db.invoice.findUnique({ where: { id } });
if (invoice.ownerId !== session.userId) throw forbidden();
// Sturdier: the query cannot return what the user cannot see.
const invoice = await db.invoice.findFirst({
where: { id, ownerId: session.userId },
});
if (!invoice) throw notFound();The second version fails closed if someone forgets to write the guard, because there is no guard to forget — the constraint is in the query. That is the difference between a fix and a policy, and it is the only version that survives the next twelve months of feature work.
Related
Understand the application before you test it
Scanners find what they were told to look for. The bugs that matter live in the gap between what an application believes about itself and what it actually enforces.
- Web Security
- OWASP
- Methodology
The JWT mistakes that still work in 2026
Algorithm confusion, unverified signatures and secrets that were never secret. A tour of the JWT failures I keep finding in labs, and what each one actually requires to exploit.
- Web Security
- JWT
- Authentication
Building a CTF platform taught me more security than solving CTFs
Running code that is designed to be attacked forces a kind of threat modelling that solving challenges never does. Notes from building Hackuten.
- Web Security
- Research
- Docker

