IDOR and broken access control: the #1 web vulnerability, explained
If you test web applications for a living, or you’re learning to, you’ll find broken access control more often than any other serious issue. It has sat at the top of the OWASP Top 10 since 2021 (see the official OWASP Top 10 project for the underlying data), appearing in the large majority of tested applications, and its most common form, IDOR, is among the most reported vulnerabilities in bug bounty programs.
The reassuring news is that access-control flaws are conceptually simple. The uncomfortable news is that they’re everywhere, because the fix requires discipline applied consistently across an entire codebase. This guide covers how they work, how to find them, and how to fix them properly.
Authentication vs authorisation: the distinction that matters
Almost every access-control bug traces back to confusing two ideas:
- Authentication is proving who you are. Logging in. Frameworks handle this well.
- Authorisation is checking what you’re allowed to do. Whether this specific authenticated user may access this specific resource. This is custom logic, and it’s where things break.
Broken access control is an authorisation failure. The user is correctly authenticated, they really are logged in as themselves, but the application never properly checks whether they’re allowed to perform the action they’re attempting. It authenticates and then trusts.
What is IDOR?
IDOR (Insecure Direct Object Reference) is the most common form of broken access control. It happens when an application exposes a reference to an internal object (a database record, a file, an account) and fails to verify that the requesting user is authorised to access that particular object.
The classic example is an identifier in a URL:
GET /api/v1/invoices/1042 → returns your invoice
GET /api/v1/invoices/1043 → returns someone else's invoice
Change one number and you get another user’s data, with no elevated privileges and no clever payload required, just an enumerable identifier and a missing ownership check on the server. That’s IDOR in its purest form.
It applies to any action, not just reading:
GET /api/v1/orders/5001 → view an order that isn't yours
POST /api/v1/orders/5001/cancel → cancel an order that isn't yours
DELETE /api/v1/files/8830 → delete a file that isn't yours
Whenever the server acts on an object based purely on an identifier the user supplied, without confirming ownership, you have IDOR.
Horizontal vs vertical: the two directions of escalation
Access-control failures let a user reach things they shouldn’t, in one of two directions.
Horizontal privilege escalation
Accessing the data of another user at the same privilege level. User A reads User B’s invoices, messages, or profile. The invoice example above is horizontal escalation. In API terms this is BOLA (Broken Object Level Authorisation), the number-one risk on the OWASP API Security Top 10, because modern apps address every object by ID.
Vertical privilege escalation
A lower-privileged user reaching higher-privileged functionality. A normal user performing admin actions. The obvious /admin panel is usually protected, but the forgotten API variant often isn’t:
GET /admin/users → 403, correctly blocked
GET /api/admin/users → 403 as well... usually
GET /api/v1/admin/users → this route variant was never protected
Different URL structures pass through different middleware stacks. If authorisation is applied per-route instead of per-resource, gaps like this are almost guaranteed to appear as an application grows.
Related access-control flaws to know
Broken access control is broader than IDOR alone. Watch for:
- Forced browsing: reaching pages or endpoints by guessing their URL, relying on them being “unlinked” rather than protected.
- Mass assignment or parameter tampering: adding an unexpected field to a request, like
"role": "admin"or"is_verified": true, that the server blindly binds to the object. - Missing function-level access control: the client hides an admin button, but the underlying endpoint has no server-side check, so anyone who calls it directly succeeds.
- Metadata manipulation: tampering with a JWT, cookie, or hidden field that encodes the user’s role or identity.
They share one root cause: trusting the client instead of enforcing authorisation on the server.
How testers find access-control bugs
Finding IDOR is less about tools and more about careful observation, the same slow, question-driven hunting mindset that separates good testers from scanner operators. The reliable method uses two accounts:
- Create two users, call them A and B, ideally with some data in each.
- Act as User A with a proxy like Burp Suite capturing every request. Note every request that references an object by ID: invoices, orders, messages, files, profile fields.
- Replay A’s requests using B’s session. Take a request that legitimately accessed A’s object, swap in B’s session token, and keep A’s object ID. Does the server return A’s data to B? If yes, that’s a broken object-level authorisation.
- Enumerate and tamper. Try incrementing and decrementing IDs. Try adding privileged fields to write requests. Try the API variants of protected admin routes.
- Test every verb. An object might be readable only to its owner but deletable by anyone who knows the ID. Check
GET,POST,PUT,PATCH, andDELETEindependently.
The tell-tale sign is always the same: the server acts on an object based on the ID you supplied without confirming you’re allowed to touch it.
Why UUIDs don’t save you
A common misconception: “we use random UUIDs instead of sequential IDs, so we’re safe from IDOR.” You’re not.
Random identifiers make objects harder to discover by guessing, since an attacker can’t simply count from 1042 to 1043. But that’s obscurity, not access control. If the ownership check is missing, an attacker who obtains a valid identifier through any channel, a shared link, an API response that lists IDs, a Referer header, a mobile app, server logs, a support ticket, can still access the object.
UUIDs are a reasonable defence-in-depth measure that raises the effort of enumeration. They are never a substitute for the real fix.
How to fix broken access control
The fix is simple to state and requires discipline to apply everywhere.
1. Enforce authorisation on the server, for every request
Every request that accesses a resource must verify, on the server, that the authenticated user is permitted to access that specific resource. Not “is the user logged in,” but “does this user own, or have explicit permission for, this exact object.”
def get_invoice(invoice_id, current_user):
invoice = db.get_invoice(invoice_id)
if invoice is None or invoice.owner_id != current_user.id:
raise Forbidden() # deny by default
return invoice
The ownership check, invoice.owner_id != current_user.id, is the entire fix. It must exist on every path that touches the object.
2. Deny by default
Design the system so that access is denied unless explicitly granted. A forgotten endpoint should fail closed, not open. New routes should inherit “no access” and require a deliberate grant, so the failure mode of forgetting a check is a locked door, not an open one.
3. Centralise the check where it can’t be bypassed
Applying authorisation ad hoc in each controller guarantees inconsistency. Enforce it at the data-access layer or through a shared policy mechanism, so the same object can’t be reached through a different route that forgot the check. The more central the enforcement, the fewer gaps.
4. Never trust the client
Hiding a button, disabling a field, or omitting a link is a UI convenience, not a security control. Every one of those actions must be re-checked on the server. Assume the attacker crafts requests by hand, because they do.
5. Reject unexpected fields
Defend against mass assignment by explicitly binding only the fields a user is allowed to set. If a request includes role or owner_id and the user has no business setting them, reject or ignore them rather than binding them to the object.
Key takeaways
- Broken access control is the most common serious web vulnerability, and IDOR is its most reported form.
- It’s an authorisation failure: the user is authenticated, but the server never checks whether they’re allowed to access the specific resource.
- Escalation runs horizontally (other users’ data, BOLA in APIs) and vertically (reaching admin functionality).
- UUIDs don’t fix it. Only a server-side ownership check does.
- Fix it by enforcing authorisation on the server for every request, denying by default, centralising the check, and never trusting the client.
Access-control bugs persist because fixing them takes discipline applied to every endpoint, forever, not a library you install once. Understand it well and you’ll both find these bugs as a tester and prevent them as a builder.
For the defensive patterns in code, the OWASP Authorization Cheat Sheet is the reference worth keeping open, and PortSwigger’s free access control labs give you a legal target to practise every variant described above.
Keep going: read the OWASP Top 10 breakdown, the API security testing guide, or start with the bug bounty beginner’s roadmap. When you’re ready for structured, hands-on learning, explore our training.