What you'll learn
Quick Answer
Insecure Direct Object Reference (IDOR) happens when an endpoint fetches a resource by ID but never checks whether the logged-in user actually owns it. Anyone who is authenticated can read or modify someone else's data just by changing a number in the URL. The fix is a single ownership check on every resource-by-ID lookup, comparing the resource's owner to the current user before returning anything.
The One Missing Check
Authentication answers "who is this?". Authorization answers "is this person allowed to see this specific thing?". IDOR is what happens when an application does the first and quietly skips the second.
The pattern is almost always the same: a resource is stored with a sequential or otherwise guessable ID, an endpoint accepts that ID directly from the URL or request body, and the handler fetches the record and returns it to whoever asked, as long as they were logged in at all. Nothing in the middle asks whether this record belongs to this user.
It is one of the most common vulnerabilities found in real audits precisely because it requires no special tooling to exploit. An attacker with a completely legitimate, valid account for the application just edits a number in a URL they were already allowed to send requests to.
The Vulnerable Endpoint
Here is the shape of the bug in an invoice-lookup endpoint:
app.get('/invoices/:id', (req, res) => {
const invoice = db.find(req.params.id);
if (!invoice) return res.sendStatus(404);
// BUG: no check that invoice.ownerId === req.user.id
res.json(invoice);
});The login check happened earlier in the middleware chain, so req.user is a real, authenticated user. The endpoint just never asks whether that user is the right user for this particular invoice. Any authenticated account can request any invoice ID and get a valid, fully-populated response back.
This exact shape is common in scaffolded CRUD APIs, where a framework or code generator produces a "get by id" route straight from the database schema. The generator knows how to look up a row by its primary key; it has no way of knowing which foreign key on that row represents an owner, so the check has to be added by hand, and it's easy to ship the endpoint before anyone does.
The Live Exploit
I ran this against real data: two accounts, Alice (user id 1) and Bob (user id 2), each with their own invoice. Alice fetching her own invoice works exactly as expected:
GET /invoices/101 (X-User-Id: 1)
Response status: 200
body: {"id":101,"ownerId":1,"amount":4999,"description":"Alice's Pro plan renewal"}Now Alice, still authenticated as herself, requests invoice 102 instead, which belongs to Bob:
GET /invoices/102 (X-User-Id: 1, still Alice)
Response status: 200
body: {"id":102,"ownerId":2,"amount":1999,"description":"Bob's Starter plan renewal"}That's Bob's billing amount and plan details, handed to Alice, because the only thing the endpoint checked was that someone was logged in.
Nothing here required a proxy tool, a script, or any special access. Alice was using her own valid session the entire time; the only action was typing a different number into a URL she already had permission to load. That's what makes IDOR so consistently exploitable in practice: the barrier to finding it is curiosity, not skill.
The Fix: Check Ownership, Not Just Login
The fix is one extra comparison, run after the record is fetched and before it is returned:
app.get('/invoices/:id', (req, res) => {
const invoice = db.find(req.params.id);
if (!invoice) return res.sendStatus(404);
if (invoice.ownerId !== req.user.id) return res.sendStatus(404);
res.json(invoice);
});Running the identical attack against the fixed endpoint:
GET /invoices/102 (X-User-Id: 1)
Response status: 404
body: {"error":"not found"}Alice's own invoice still works exactly as before, and Bob fetching his own invoice 102 with his own session still gets a normal 200. The only thing that changed is that a mismatched owner now gets rejected instead of served.
The same one-line check has to be applied everywhere a resource is looked up by ID, not just on this endpoint, which is why teams that take IDOR seriously often push it into a shared middleware or a data-access layer instead of trusting every individual handler to remember it.
Why Return 404, Not 403
A 403 Forbidden confirms that invoice 102 exists, it just isn't yours. An attacker can use that confirmation to script through every ID and map out exactly which ones are real accounts, even without ever reading their contents. Returning 404 for both "doesn't exist" and "exists but isn't yours" removes that signal.
This is a defense-in-depth detail, not the actual fix, the ownership check itself is what matters. But it costs nothing to implement and it denies an attacker a free enumeration oracle, so most production APIs that take IDOR seriously make this choice deliberately rather than by accident.
Consider a competitor trying to estimate how many customers you have by sweeping sequential invoice IDs and counting how many return 403 versus a generic error. A consistent 404 for every ID that isn't theirs gives that sweep nothing to distinguish real accounts from ones that don't exist at all.
IDOR Isn't Just GET Requests
The same missing check shows up on PUT, PATCH, and DELETE endpoints, and it's often worse there because the consequence is a modification or deletion instead of a read. It also shows up in file downloads keyed by an ID in a query string, and in bulk export endpoints that accept a list of IDs and never filter it against the caller's own records.
Switching sequential integers to random UUIDs makes IDs harder to guess, but it does not fix authorization on its own. An attacker who obtains one valid UUID, from a leaked link, a referrer header, or a chat log, can still access it if the ownership check is missing. Unpredictable IDs raise the cost of guessing; they don't replace the check.
