A junior developer ships an invoice page. It works, it is behind a login, and it passes review. The URL looks like this:
GET /api/invoices/1042Six months later a customer idly changes 1042 to 1041 and finds another company's invoice — their address, their line items, their spend. Nothing was cracked. No password was stolen. The application did exactly what it was written to do.
That's IDOR, and it is probably the most common serious bug in web applications.
Authentication is not authorization
This is the entire bug in one sentence, and it is worth being precise about the two words because they get used interchangeably in conversation and mean completely different things in code.
Authentication answers who are you. Authorization answers are you allowed to do this specific thing to this specific object.
Almost every IDOR is a system that nailed the first and skipped the second:
// The session is valid. The user is logged in. This is still broken.
app.get("/api/invoices/:id", requireLogin, async (req, res) => {
const invoice = await db.invoice.findById(req.params.id);
res.json(invoice);
});requireLogin confirms there is a real user attached to the request. It says nothing whatsoever about whether this user has any relationship to invoice 1042. The middleware looks like a security control, which is what makes the bug so easy to miss in review — the line that would have caught it was never written, and absence is hard to see.
No ownership check — any authenticated user can access any order by ID
Logged in as
Request order
Server response
Why this survives code review
Three reasons, and they compound.
It reads as correct. Nothing in the handler above looks wrong. There is no dangerous function, no string concatenation, no obviously missing escape. Reviewers scan for the presence of bad things far more reliably than for the absence of good ones.
The happy path always works. Every test passes. Every manual QA click succeeds, because testers use the UI, and the UI only ever links to objects you own. The bug is only reachable by editing a request by hand — which is to say, by doing the one thing no automated test does.
Scanners are bad at it. A scanner can spot a SQL injection because the payload produces a recognisable signal. IDOR produces a 200 OK with a perfectly normal-looking response body. The tool has no idea whether invoice 1041 was supposed to be visible to you; only your business logic knows that.
The UUID mistake
The most common reaction to a reported IDOR is to make the identifiers unguessable:
// "Fixed": now the IDs are UUIDs.
GET /api/invoices/9f8b2e1a-4c7d-4b2e-9a31-6f0d8e2c1b5aThis is not a fix. It is a delay, and often not much of one.
The authorization check is still missing — the vulnerable code is byte-for-byte identical. All that changed is how much work it takes to obtain a valid identifier, and identifiers leak in more places than people expect: shared links pasted into tickets, Referer headers sent to third parties, server logs, CSV exports, analytics payloads, error messages, and — most commonly — other endpoints in your own API that happily list objects belonging to other people.
Once an attacker has one valid identifier, the request succeeds exactly as it would have with 1042. Security through unguessability is a speed bump in front of an open door.
That said, sequential integers are worse, for a reason unrelated to authorization: they leak business information. /invoices/1042 tells any customer roughly how many invoices you have ever issued, and two orders placed a week apart reveal your volume. Use UUIDs for that reason. Just don't file it under "access control".
The fix: make the wrong row impossible to return
The instinct is to fetch the object and then check it:
const invoice = await db.invoice.findById(req.params.id);
if (invoice.userId !== req.user.id) return res.sendStatus(404);
res.json(invoice);This is correct, and it is fragile. It relies on every developer, in every handler, forever, remembering to write the second line. It fails open — forget the check and the endpoint still returns 200, still passes tests, and still ships.
Scope the query instead, so there is no unauthorized row to leak in the first place:
// The database cannot return a row this user doesn't own.
const invoice = await db.invoice.findOne({
where: { id: req.params.id, userId: req.user.id },
});
if (!invoice) return res.sendStatus(404);
res.json(invoice);Now forgetting the ownership constraint produces a visibly broken feature rather than a silent vulnerability. Fail closed: make the insecure version the one that obviously doesn't work.
Return 404, not 403
A 403 Forbidden confirms that invoice 1041 exists and belongs to someone else. That is a free enumeration oracle — an attacker can walk the ID space and map exactly which records exist. 404 for both "no such object" and "not yours" gives away nothing.
When ownership isn't the rule
Plenty of real systems are more complicated than userId = me. A manager can see their reports' records; a support agent can see any account during an open ticket; a shared document has an access list.
For those, put the decision in one place and make every handler go through it:
// One policy module. Every read goes through it. No exceptions.
if (!(await can(req.user, "read", invoice))) return res.sendStatus(404);The point isn't the specific shape — it's that a scattered rule is a rule that will eventually be forgotten in one handler out of eighty, and that one handler is the whole vulnerability.
Where to look in your own codebase
Search for handlers that take an identifier from the request and pass it straight to a lookup. In practice the highest-yield places are:
- Anything with
/:idthat isn't a public resource. Start here. PUT,PATCHandDELETE, not justGET. Read access gets the attention; write access gets the damage. An IDOR onDELETE /api/documents/:idis considerably worse than one on a read.- Nested routes.
/orgs/12/projects/98frequently checks membership of org 12 and then loads project 98 without confirming project 98 belongs to org 12. - Bulk and export endpoints.
POST /api/invoices/exportwith a body of{"ids": [1041, 1042, 1043]}often loops without re-checking each element. - Anything added in a hurry. Mobile-only endpoints, admin tooling, and "temporary" internal APIs are where checks go missing.
Try it
Reading about IDOR does not build the instinct. Changing a number in a request and watching someone else's data come back does — and then closing the hole yourself makes the fix stick.
The simulation below is a working vulnerable order-lookup endpoint. Exploit it first, then apply the scoped query and watch the same request start returning 404.
Frequently Asked Questions
Related posts
Race Conditions in Web Apps: When Two Requests Beat Your Check
Check-then-act logic that looks correct on paper breaks when two requests run at once. Here's how TOCTOU race conditions drain balances and reuse one-time codes, and how atomic operations and locks fix them.
Aug 11, 2026 · 7 min readMass Assignment: How role=admin Ends Up in Your Update
Mass assignment lets an attacker set fields you never meant to expose — like isAdmin or accountBalance — by adding them to a request body. Here's why binding whole objects is dangerous and how to allowlist fields in Node, Rails, and Django.
Aug 10, 2026 · 6 min readInsecure Deserialization: How Loading Data Becomes Running Code
Deserializing untrusted data can execute code before your app reads a single field. Here's how gadget chains work in Python pickle, Java, PHP, and Node, and why JSON with a schema is the fix.
Aug 9, 2026 · 7 min read