Broken access control and IDOR
Why returning 403 for someone else's resource is itself a leak, and why Linkstash returns 404 instead, with the real routes and requests to prove it.

"Logged in" and "allowed" are two different questions, and the bug that breaks the most real apps is treating them as one. A valid session proves who you are. It says nothing about which of the millions of rows in your database you're supposed to be able to touch.
This is Insecure Direct Object Reference, IDOR for short, and it's still one of the most common ways a working app leaks other people's data. It doesn't need a clever exploit. It needs a number in a URL and a server that forgot to check whose number it is.
The bug in its most common shape
Say GET /links/:id looked like this, checking only that the caller is logged in, not that the link is theirs:
app.get("/links/:id", requireAuth, (req, res) => {
const link = getLink(db, Number(req.params.id));
if (!link) {
res.status(404).json({ error: "NotFound" });
return;
}
res.json(link); // anyone logged in can read anyone's link
});Aarav creates account 4, saves a link, and it becomes /links/17. Nothing about that request requires the link to belong to Aarav. Any logged-in user, Maya, Kabir, anyone with a valid token, can request /links/1, /links/2, /links/3, and just walk the counter up. requireAuth checks that a token is valid. It never checks that the token's owner matches the resource's owner. Sequential integer IDs make this especially easy to explore. There's nothing to guess, just count.
The fix that's still wrong
The instinct is to add an ownership check and reject anyone who fails it:
app.get("/links/:id", requireAuth, (req, res) => {
const link = getLink(db, Number(req.params.id));
if (!link) {
res.status(404).json({ error: "NotFound" });
return;
}
if (link.userId !== req.userId) {
res.status(403).json({ error: "Forbidden" }); // looks right, still leaks
return;
}
res.json(link);
});Better, but watch what an attacker learns from the two failure cases. A 404 on /links/9999 means nothing exists there. A 403 on /links/17 means something exists there, and it belongs to someone else. Just by comparing status codes across a range of IDs, an attacker maps out exactly which link IDs are real without ever reading their contents. That's a smaller leak than reading the data outright, but it's still information the requester had no business getting, and it's often the first step toward a bigger one.
What Linkstash actually does
GET /links/:id and DELETE /links/:id return the identical 404 whether the link doesn't exist or simply isn't yours:
// Not-yours and not-found deliberately return the same 404. See API.md.
app.get("/links/:id", requireAuth, (req, res) => {
const link = getLink(db, Number(req.params.id));
if (!link || link.userId !== req.userId) {
res.status(404).json({ error: "NotFound" });
return;
}
res.json(link);
});
app.delete("/links/:id", requireAuth, (req, res) => {
const link = getLink(db, Number(req.params.id));
if (!link || link.userId !== req.userId) {
res.status(404).json({ error: "NotFound" });
return;
}
deleteLink(db, link.id);
res.status(204).end();
});One condition, !link || link.userId !== req.userId, and both failure reasons fall into the same branch, producing the same status and the same body. From outside the response, "doesn't exist" and "exists but isn't yours" are indistinguishable. That's the entire fix. Not smarter validation, just refusing to let the response shape carry information the caller isn't entitled to.
Watch it in the API itself
The panel below sends real requests through a simulated Linkstash, matching the actual route table in reference/linkstash/API.md. Try the three buttons and compare the responses:
/links/1 with a valid token returns 200 because it belongs to that token's user. /links/2 with the exact same valid token returns 404, the same status you'd get for a link ID that was never assigned at all. /links/2 with no Authorization header returns 401 instead, a completely separate failure for a completely separate reason: that check runs before the app ever looks at the link. Three requests, three distinct situations, and only the boundary that matters (yours or not) gets collapsed into one indistinguishable response.
Quick check
Why is returning 403 Forbidden for another user's resource considered a leak, even though it correctly blocks access?
Random IDs are not the fix, they're a second layer
A common suggestion here is to switch from sequential integers to UUIDs, so IDs can't just be counted up. It helps, genuinely, an attacker can no longer enumerate by incrementing a number. But it doesn't fix the underlying bug. A UUID is still guessable if it leaks anywhere else, a log line, a referrer header, a support ticket, and a route with no ownership check is exactly as broken with random IDs as with sequential ones. Treat unguessable IDs as defense in depth, not a substitute for checking link.userId !== req.userId on every route that takes a resource ID as input, on every method, every time. That check is the fix. Everything else just raises the cost of finding a valid ID in the first place.
The pattern to check for everywhere
Any route shaped like GET /:resource/:id, PATCH /:resource/:id, or DELETE /:resource/:id needs the same question answered before it does anything else: does the ID in this URL belong to the ID in this session? requireAuth answers "is this a real session." It has to be paired with a second check that answers "does this session own this row," on every single route that takes an ID, not just the ones you remembered to test. If you've written tests for these boundaries before, why write tests is worth a look for exactly this kind of case, one that's easy to skip because the happy path works fine.
Next: security headers and CSP, the defenses that cost almost nothing to add and stop entire categories of attack before your route code even runs.

Written by
Rhythm Bhiwani
Engineer and relentless builder, happiest reverse-engineering hard problems until they click.
Enjoyed this?
Tap the heart to leave some love.
Be the first to react
Comments
Join the conversation.
Loading comments…


