Insecure Direct Object References (IDOR): Breaking Access Control
Insecure direct object references (IDOR) let a logged-in user read or change records that are not theirs by editing an id. Learn to find, exploit in a lab, and fix them.
Insecure direct object references (IDOR) are the access control flaw you meet most often in real applications, and the easiest class of bug to explain to someone who has never read a line of security research: the server hands you an object because you asked for it by name, and it never checks whether that object was yours to ask for. You change a number in a URL, the record belongs to a different account, and the page loads anyway. No payload, no injection, just a reference the application trusted when it should not have.
This guide builds the mental model first, then walks through finding and confirming the bug, a worked read of another user's record on lab.0xpriv.dev, the API variants that hide the same mistake, and the one control that closes all of it. Read it top to bottom once, then keep it open as a checklist for your next test.
Everything here is for an application you own or are explicitly authorized to assess, or a deliberate lab such as lab.0xpriv.dev. Reading or changing another person's records on a system you do not have written permission to test is a crime in most countries. Stay in your lab.
01What insecure direct object references (IDOR) really are
An insecure direct object reference is an access control failure, not an input-handling failure, and the distinction matters because it tells you where the fix lives. The application exposes a direct handle to a back-end object, usually a database primary key in a URL, a form field, or a JSON body, and then acts on that handle without checking that the current session is allowed to touch the object behind it. The input itself is perfectly valid. The value id=334 is a normal integer. The problem is that the server treated "the user asked for 334" as if it meant "the user owns 334".
That is why people file IDOR under broken access control rather than under injection or validation. There is nothing malformed to filter. Stripping quotes, encoding output, or adding a web application firewall rule does nothing, because the request looks exactly like a legitimate one from a different user. The only correct answer is an authorization decision made on the server for every object the request reaches.
OWASP ranks broken access control as the first entry in its Top Ten for a reason: it is both common and high impact, and IDOR is its most recognisable shape. The underlying weakness has a dedicated identifier, CWE-639, authorization bypass through user-controlled key, which describes the pattern precisely: a key supplied by the client decides which record is returned, and the server trusts it.
If you can log in to a web app, open a request in a proxy, and change a value before it is sent, you can follow this. The hard part is not the tooling, it is learning to ask "who is allowed to see this?" for every object a request names.
02How object references leak into the client
Object references leak whenever the application puts a back-end identifier somewhere the user can see and edit. Once you know where to look, you find them everywhere, because almost every page is built around "show me this specific thing". The four classic sources below cover the large majority of real findings.
| Where it appears | Example |
|---|---|
| Sequential IDs in the URL | /invoice?id=1042, next one is 1043 |
| Predictable keys in a path | /users/5001/profile, guess neighbours |
| Hidden form fields | <input type="hidden" name="account" value="88"> |
| API parameters and bodies | GET /api/orders/7781 or a JSON "userId": 88 |
Sequential integers are the giveaway. If your own account is id 5001 and the invoice you just downloaded is numbered 1042, both counters are rising predictably, and nothing stops you asking for 1041. Hidden form fields are just as common and often forgotten, because developers assume a value the browser does not display is a value the user cannot change. A proxy makes that assumption false in one click.
The diagram is the whole bug in three boxes. The missing piece is a gate between the second and third box that asks whether the session that sent the request owns record 334. When that gate is absent, the reference is "direct" and "insecure", which is exactly what the name describes.
03Finding IDOR by tampering with identifiers
Finding IDOR means tampering with every identifier a request carries and watching what comes back. The method is boring in the best way: catalogue the handles, change them one at a time, and compare the response to what you expect. You are not looking for an error, you are looking for success where the application should have refused you.
- Map the identifiers. Browse the app as a normal user with a proxy such as Burp Suite recording. Note every numeric id, hash, or key in URLs, query strings, hidden fields, cookies, and JSON bodies.
- Create two accounts. Register user A and user B. This gives you a known "yours" object and a known "theirs" object, so you can tell a real violation from a resource you were always allowed to see.
- Swap the value. As user A, replay a request but substitute user B's id. A profile, an order, a document, a message thread.
- Read the result. A 200 with user B's data is an IDOR. A 403, a 404, or a redirect to your own record is the application doing its job.
- Walk the range. If one swap works, increment and decrement to confirm it is systemic and not a single misconfigured route.
The two-account method is the part beginners skip, and it is what separates a finding from a guess. Without a second account you cannot prove that the record you pulled was off limits, you can only claim it. With user B in hand, the before-and-after is undeniable: user A, logged in as user A, is holding user B's data.
04Horizontal versus vertical access violations
Access violations split into two directions, and naming which one you have found tells the owner how bad it is. A horizontal violation is reaching data that belongs to another user at your own privilege level. A vertical violation is reaching functions or data reserved for a higher privilege level, such as a standard user triggering an admin-only action. Most IDOR is horizontal, but the same missing check often opens the vertical door as well.
Why does the direction matter for your report? A horizontal IDOR on a billing record is a confidentiality breach of every customer. A vertical IDOR that lets a plain user call /admin/users/88/delete is a takeover of the whole tenant. Both come from the same root cause, a server that checks you are logged in but never checks what you are allowed to do, so fixing one usually fixes the other.
05A worked example on lab.0xpriv.dev
This worked example reads another user's account record on lab.0xpriv.dev by changing a single id, so you can see the full loop end to end. The lab app shows your own account at /account?id= and pulls the row straight from its primary key. We are logged in as user A, id 5012. User B, id 5013, is a second account we registered so we have something safe to target.
Start by capturing the normal, legitimate request for your own record. This is the baseline, the request the application expects.
GET /account?id=5012 HTTP/1.1
Host: lab.0xpriv.dev
Cookie: session=a-valid-session-for-user-A
Accept: text/htmlNow send the same request with one change, the id of user B, keeping user A's session cookie intact. If the server returns user B's account, there was no ownership check behind the reference.
$ curl -s "http://lab.0xpriv.dev/account?id=5012" -b "session=userA" | grep -o 'email:.*' email: [email protected] $ curl -s "http://lab.0xpriv.dev/account?id=5013" -b "session=userA" | grep -o 'email:.*' email: [email protected]
User A, authenticated as user A, just read user B's email out of the record store. The second line is the proof: the session never changed, only the id did, and the application served a row the session had no right to. In a real assessment you would now walk the id range and note how many records are exposed, then stop, because reading more data than you need to prove the point is both unnecessary and out of scope.
Two records, yours and one other, are enough to demonstrate the bug. Resist the urge to scrape the whole table. The finding is "the control is missing", and one swap shows that. Mass extraction turns a clean report into a mess and can break the terms of your engagement.
06IDOR in APIs and the mass-assignment next door
APIs concentrate IDOR because they are built almost entirely out of object references, and the same request that leaks data often lets you write it too. A REST route like GET /api/orders/7781 is an IDOR the moment the handler loads order 7781 without checking the caller owns it. The method matters as much as the path: a PATCH or PUT against someone else's object is a write primitive, not just a read.
PATCH /api/users/5013 HTTP/1.1 Host: lab.0xpriv.dev Authorization: Bearer token-for-user-A Content-Type: application/json {"email": "[email protected]"}
If that succeeds, user A has rewritten user B's email, which in many apps is the first step to a password reset takeover. Sitting right next to this is mass assignment, a closely related bug where the server binds every field in the JSON body straight onto the model. You send a field the UI never exposes, and the framework saves it because it blindly maps input to columns.
PATCH /api/users/5012 HTTP/1.1 Host: lab.0xpriv.dev Authorization: Bearer token-for-user-A Content-Type: application/json {"displayName": "A", "role": "admin", "accountBalance": 999999}
Here user A is editing their own object, id 5012, so there is no IDOR, but the app accepts role and accountBalance from the request body and binds them. That is a vertical escalation through mass assignment. The two bugs travel together because both come from trusting client input to decide server-side state: IDOR trusts the client to name the object, mass assignment trusts the client to name the fields.
A readable IDOR leaks data. A writable one, or one paired with mass assignment, changes it: reset an email, flip a role, move a balance. Always test the write methods, not just GET, and rate the finding on what the worst accepted request can do.
07Why UUIDs help but are not access control
Unguessable identifiers raise the cost of guessing, but they are not an access control, and treating them as one is a trap. Swapping a sequential id=5013 for a random UUID like 7c9e...a1 means an attacker can no longer increment their way through your records, which removes the easy mass-enumeration. That is a real improvement, and it is worth doing. It is just not the fix.
GET /account/7c9e6a1f-2b44-4f0a-9c3d-1e55a0d3b2f1 HTTP/1.1 Host: lab.0xpriv.dev Cookie: session=userA
The reason UUIDs fall short is that identifiers leak. They show up in shared links, referrer headers, server logs, browser history, support tickets, exported reports, and third-party analytics. The moment an attacker learns one valid UUID that is not theirs, the missing ownership check hands over the record exactly as it would for a sequential id. Randomness only slows discovery of the handle. It never answers the real question, which is whether the caller is allowed to use the handle once they have it.
Random identifiers plus a server-side ownership check give you defence in depth: the attacker cannot guess the handle, and even if they find it, the authorization check refuses them. Use random ids as a hardening measure, never as the control.
08Detecting IDOR attempts in your logs
Detecting IDOR is about spotting authorization anomalies in behaviour, because the requests themselves are individually valid and a payload signature will never catch them. One session walking a tight range of object ids, id=5012, 5013, 5014, in quick succession is the enumeration pattern. A spike in 403 responses from a single account, or a single account suddenly touching objects it has never referenced before, is the same bug probed more carefully.
$ grep 'session=userA' access.log | grep -oE 'id=[0-9]+' | sort -u | wc -l altogether 4127 distinct account ids from one session in an hour
The useful signals are relationships your app already knows: how many distinct objects one principal reached, how many of those it owns, and how far across the id space a single session ranged. Log the authenticated user id alongside every object id, then alert when one user reads many objects they do not own, or when owned-versus-requested ratios fall off a cliff. That is an authorization anomaly, and it is far more reliable than trying to match a request string.
09Shutting IDOR down
Closing IDOR comes down to one principle applied everywhere: the server, not the client, decides which object the caller may touch, and it decides on every single request. The checks below are ordered from the control that actually fixes the bug to the measures that harden around it.
// the fix: authorize the object against the session, server side, every time // 0xpriv.dev/web $order = $repo->find($id); // load by the client-supplied id if (!$order || $order->ownerId !== $session->userId) { http_response_code(404); // deny by default, do not confirm it exists exit; } echo render($order); // only reached if the session owns it
WHERE id = ? AND owner_id = ? over fetching by id and checking afterwards, so an unowned object is never loaded in the first place.Centralising the check is what makes this stick. A middleware layer, a policy object, or a repository method that always scopes to the owner means a developer adding a new endpoint gets the control for free instead of having to remember it. Scattered per-handler checks are how one forgotten route reopens the whole class.
10FAQ
Is IDOR an injection bug or an access control bug?
An access control bug. The input is a valid identifier, so there is nothing to sanitise or encode. The flaw is that the server acts on a client-supplied reference without checking the caller is allowed to touch the object behind it, which is why OWASP files it under broken access control and CWE tracks it as CWE-639.
Do random UUIDs fix IDOR?
No. They make the identifier hard to guess, which blocks easy enumeration, but identifiers leak through links, logs, referrers, and exports. Once an attacker holds a valid id that is not theirs, a missing ownership check serves the record regardless of how random the id was. Use UUIDs as hardening on top of a real server-side authorization check.
What is the difference between horizontal and vertical access violations?
Horizontal means reaching another user's data at your own privilege level, such as reading a peer's invoice. Vertical means reaching higher-privilege data or functions, such as a standard user calling an admin-only action. The same missing check often enables both, so test for each direction.
How is IDOR related to mass assignment?
They are neighbours that share a root cause: trusting client input to decide server state. IDOR trusts the client to name which object to act on, mass assignment trusts the client to name which fields to set. A single request can carry both, for example editing your own object but injecting a privileged field like role.
How do I prove an IDOR without over-collecting data?
Register two accounts. As user A, request user B's object and show that user B's data comes back under user A's session. That before-and-after is proof the control is missing. Then stop. One extra record demonstrates the bug; scraping the full table is unnecessary and usually outside the scope of your engagement.
11Wrapping up
Insecure direct object references come down to a question the application forgot to ask: not "is this input clean?" but "is this caller allowed to touch this object?". Find them by cataloguing every identifier and swapping it with a second account in hand. Report them by direction and by whether the dangerous methods are writable. Fix them by moving the authorization decision to the server and running it on every request, scoped to the owner, deny by default. Random ids and indirect maps make the attacker's job harder, but the control that closes the class is the ownership check itself.
references
Founder of 0xpriv, covering offensive security, web application vulnerabilities, and practical security research through PHP, Python, and hands-on technical analysis.