Every good bug bounty career starts the same way: an IDOR. It’s the first vuln class you meet in Bug Bounty Hunter —Idle, and it’s usually the first one new researchers find in the wild too - not because it’s exotic, but because it’s everywhere. Insecure Direct Object Reference bugs are boringly simple to cause and expensive to have. This write-up goes past the “change the ID in the URL” one-liner and into why the bug keeps happening, how to find it methodically, and what actually fixes it (hint: it isn’t UUIDs).
What an IDOR actually is
An IDOR happens when an application lets a user reference an object (a record, a file, an account) directly by its identifier, without checking that the requesting user is actually allowed to access that specific object. The vulnerability isn’t the reference itself; it’s the missing authorization check behind it.
GET /api/invoices/8841 HTTP/1.1
Authorization: Bearer <your-valid-token>
If invoice 8841 belongs to someone else and the server hands it back
anyway, you’ve found an IDOR. Nothing about that request looks malicious - no
injection, no malformed input, just a valid ID that happens to belong to
another user. That’s what makes the class so persistent: the request is
syntactically correct. The failure is entirely in server-side logic that
was never written.
IDOR falls under OWASP’s Broken Access Control category, consistently the #1 risk in the OWASP Top 10, because it’s really a symptom of a broader pattern: authentication answers “who are you?” but plenty of apps stop there and never separately ask “are you allowed to touch this?”
Why it’s everywhere
A few reasons this bug class refuses to die:
- Authentication gets tested. Authorization often doesn’t. QA and automated tests routinely confirm that a user can log in and see their own data - that’s the happy path. Far fewer suites confirm a user cannot see someone else’s, because that requires two test accounts and a cross-check on every single object-returning endpoint.
- It scales with every new endpoint. Each new
/api/<resource>/:idroute is a fresh chance to forget the ownership check - and modern apps ship a lot of endpoints, especially once a mobile app, a public API, and an admin panel are all hitting the same backend. - Frameworks don’t do it for you. An ORM will happily run
Invoice.find(params.id)regardless of who’s asking. Fetching a record and authorizing access to it are two different lines of code, and only one of them is required to get a 200 response back. - It hides behind “it worked in testing.” With one test account, every request looks correct - the bug only appears when you have a second identity to test against, which is exactly the step that gets skipped under deadline pressure.
A vulnerable example
Here’s a realistic Express handler for fetching an order - the kind of code that ships every day:
// GET /api/orders/:id
app.get('/api/orders/:id', requireAuth, async (req, res) => {
const order = await Order.findById(req.params.id);
if (!order) {
return res.status(404).json({ error: 'Not found' });
}
res.json(order);
});
requireAuth confirms the request carries a valid session - so it’s easy to
read this as “protected.” But look closely: nothing checks that
order.userId matches the logged-in user. Any authenticated account can
enumerate /api/orders/1, /api/orders/2, /api/orders/3… and read
every order in the database, including names, addresses, and order totals
belonging to other customers.
The fix is one condition, but it has to be there:
app.get('/api/orders/:id', requireAuth, async (req, res) => {
const order = await Order.findById(req.params.id);
if (!order) {
return res.status(404).json({ error: 'Not found' });
}
// The check that was missing: does this object actually belong to the
// requester (or does the requester otherwise have a legitimate reason
// to see it, e.g. an admin role)?
if (order.userId !== req.user.id && !req.user.isAdmin) {
// 404, not 403 - see "Don't leak existence" below.
return res.status(404).json({ error: 'Not found' });
}
res.json(order);
});
This is the whole bug and the whole fix. There’s no clever payload involved, and that’s precisely why it’s worth taking seriously.
Where IDORs actually turn up
Direct object references aren’t limited to numeric IDs in URLs. The same missing-check pattern shows up anywhere an identifier crosses a trust boundary:
- REST path/query params -
/api/users/1042,?invoice_id=8841 - Request bodies - a
PATCHorPUTpayload with anaccountIdfield the server trusts instead of deriving from the session - GraphQL resolvers - a
node(id: "...")query or a mutation argument that skips the same ownership check a REST route would need - File/object storage paths -
/uploads/user-3391/resume.pdf, or a presigned URL scheme that doesn’t verify the requester owns that prefix - WebSocket/real-time channels - subscribing to another user’s
order:8841event channel because the join only checks that channel exists, not who’s allowed to listen - Batch/export endpoints - a “download my data” feature that accepts an array of IDs and only validates the first one, or none at all
- Indirect references that are still guessable - swapping a numeric ID for a UUID or a hash doesn’t fix an IDOR if authorization is still missing; it just makes the object reference harder to guess, not harder to access once known (e.g. leaked in another response, a shared link, or a referrer header)
That last point is worth repeating: obscurity is not authorization.
How it’s found
The methodology is almost always the same shape, whether you’re doing this manually or the game’s automating it for you:
- Create two accounts. This is the non-negotiable first step - Account A and Account B, ideally in different privilege tiers if the app has them (user vs. admin, free vs. paid tier).
- Perform an action as Account A that returns an object reference - an order ID, a document ID, a conversation ID, anything with an identifier in the request or response.
- Replay the same request as Account B, swapping in Account A’s identifier, but keeping Account B’s session/token. Not “log in as A” - the interesting case is B’s own credentials trying to reach A’s data.
- Check every ID-bearing endpoint, not just the obvious “view record”
ones - this includes update, delete, export, and any action-taking
endpoint (
/api/orders/8841/cancel,/api/invoices/8841/refund), since a write-path IDOR is usually more damaging than a read-path one. - Try both directions and both methods where relevant - GET and POST/PATCH/DELETE against the same resource, since it’s common to see a check applied to one HTTP verb on a route and forgotten on another.
Tools help enumerate candidate IDs faster, but the core insight (“does the server check ownership, or just validity?”) is a manual judgment call no scanner makes reliably on its own, which is exactly why IDOR remains one of the highest-value manual findings in bug bounty programs.
Real-world impact
This isn’t a theoretical bug class. Publicly disclosed IDOR reports have included:
- Sequential internal IDs in a ride-share app’s API exposing other riders’ trip history and pickup/drop-off locations
- A social platform’s “download your data” export accepting an arbitrary user ID parameter, handing back another account’s private messages
- A healthcare portal allowing appointment and prescription records to be read by swapping a patient ID in an authenticated request
- Password-reset and account-recovery flows that accepted a user ID in the request body instead of deriving it from the session, allowing one account to trigger a reset flow for another
The common thread: no exotic exploitation technique, just a straightforward mismatch between “authenticated” and “authorized” - and a payout that scales with how sensitive the exposed object is.
Fixing it for real
- Authorize on every object-returning and object-mutating request, derived from server-side session state - never from a client-supplied field claiming to be the owner.
- Centralize the check. Don’t hand-roll
if (obj.userId !== req.user.id)in every handler - wrap data access behind a repository/service layer or middleware that enforces ownership consistently, so a new endpoint can’t ship without it. - Prefer scoped queries over fetch-then-check where possible -
Order.findOne({ _id: id, userId: req.user.id })instead ofOrder.findById(id)followed by a manual comparison. A scoped query fails closed by construction; a forgotteniffails open. - Don’t leak existence. Returning
403 Forbiddenfor someone else’s object confirms it exists; a lookalike404 Not Foundfor both “doesn’t exist” and “not yours” avoids turning the endpoint into an ID-enumeration oracle. - Apply the same rule to admin/internal tooling. Internal panels get IDOR’d too, and often expose more per record than the public API does.
- Test it like an attacker would, not just a user. Authorization test cases need a second account, deliberately trying to cross into the first account’s data - “my own data loads correctly” is necessary but not sufficient coverage.
- Rate-limit and log ID-enumeration patterns as a defense-in-depth layer - sequential or rapidly incrementing lookups across many IDs from one session is a strong signal even before a specific check fails.
The takeaway
IDOR earns its spot at the top of the OWASP list not because it’s clever, but because it’s cheap to introduce and easy to miss - one omitted comparison, repeated across however many endpoints a real application ships. Treat “fetch the object” and “confirm the requester may access it” as two separate, both-mandatory steps on every route that touches an identifier, and this entire class of bug, and the account-takeover chains it often kicks off, stops being free money.
More write-ups in this series are on the way, working through the same vulnerability classes the game grinds through: XSS, CSRF, SQL injection, SSRF, and beyond.