Broken Access Control and IDOR Explained (OWASP #1)
A practitioner's guide to broken access control and IDOR, why scanners miss them, and how to authorize every request correctly.
Key takeaways
A practitioner's guide to broken access control and IDOR, why scanners miss them, and how to authorize every request correctly.
On this page
Broken Access Control and IDOR Explained (OWASP #1)#
Broken access control is a failure to enforce that a logged-in user can only reach the data and actions they're permitted to reach. It covers everything from a regular user hitting an admin endpoint to one customer pulling up another's invoice by changing a number in a URL. OWASP ranked it the number one risk category in its 2021 Top 10, moved up from fifth place, based on data from over 94,000 applications.
IDOR, Insecure Direct Object Reference, is the most common shape this takes: an endpoint takes an ID like /api/orders/1234, and nobody checked what happens if you change it to 1235.
What counts as broken access control?#
Access control answers one question on every request: is this user allowed to do this to this resource, right now? Authentication only answers "who is this." Authorization answers "what can they touch." Broken access control is what happens when an app does the first check and skips the second, and it shows up in recurring patterns: users acting outside their intended permissions, missing checks on API endpoints not linked from the UI, and forced browsing to pages assumed hidden rather than locked down. None of it requires exotic exploitation, just noticing a check is missing.
How is IDOR different from broken access control in general?#
IDOR is a specific, narrow case of broken access control. An endpoint accepts an identifier, an order ID, a user ID, a file key, and uses it to fetch or modify a record without confirming that record belongs to the caller. Broken access control is the umbrella category; IDOR is the single most reproducible instance, because the fix is almost always one missing if statement.
Two related failure modes fall under the same umbrella. Horizontal privilege escalation is one user accessing another's data at the same permission level: classic IDOR, where user A reads or edits user B's order because the app checks "does this order exist" instead of "does this order belong to the current session." Vertical privilege escalation is a lower-privileged user reaching functionality meant for a higher role, a regular account calling /admin/users/delete because the endpoint checks that a session exists but never checks the role attached to it, no object ID involved at all.
What does this look like in real code?#
Here's the vulnerable version. It passes every functional test, because tests use one account and never try to fetch someone else's data.
// Vulnerable: no ownership check
app.get('/api/orders/:id', requireAuth, async (req, res) => {
const order = await db.orders.findById(req.params.id);
if (!order) return res.status(404).send('Not found');
res.json(order);
});
requireAuth confirms the caller is logged in, nothing more. Any authenticated user who can guess or enumerate an order ID gets the record.
// Fixed: ownership check on every request
app.get('/api/orders/:id', requireAuth, async (req, res) => {
const order = await db.orders.findById(req.params.id);
if (!order) return res.status(404).send('Not found');
if (order.userId !== req.session.userId) {
return res.status(404).send('Not found'); // 404, not 403 — don't confirm the ID exists
}
res.json(order);
});
The fix is one comparison: does the resource's owner match the authenticated session, not any value the client sent. The 404, rather than 403, also matters, a 403 confirms the ID is valid and belongs to someone else, a small leak that helps enumerate IDs faster.
The same bug shows up in write paths, sometimes worse: a PATCH /api/users/profile that trusts a user_id field in the request body instead of the session, letting a caller edit an account that isn't theirs.
Why do sequential IDs make this worse?#
Auto-incrementing primary keys turn one missing check into a mass data exposure. If order 1234 is yours, order 1235 or 1233 almost surely belongs to someone else, and a script can walk the entire range in minutes. Guessable IDs don't cause the vulnerability, the missing ownership check does, but they turn a theoretical gap into a trivial one. Switching to UUIDs is a common mitigation, not a fix: an unguessable ID with no ownership check is still broken, just slower to exploit.
How do you find IDOR vulnerabilities?#
Manually, mostly. Scanners spot missing headers, outdated libraries, and injection patterns, but authorization bugs require knowing what a resource is supposed to belong to, business logic no scanner models. A scanner sees GET /api/orders/1234 return 200 OK with valid JSON and has no way to know that JSON belongs to someone else.
The reliable method is the two-account test: create two test accounts, generate a resource under account A, then replay the same request as account B, swapping only the ID that references A's resource. If B gets the data, or can modify it, that's IDOR. Run this against every endpoint that accepts an identifier, and check read, write, and delete separately, since it's common to find an endpoint that checks ownership on GET but not DELETE.
This is tedious, which is exactly why it gets skipped, and why it belongs in a pre-release checklist rather than treated as optional.
What's the actual fix?#
A short set of habits closes most of this category:
Authorize on every request. Don't rely on a resource being "unguessable" or hidden behind a UI that doesn't link to it. If the backend acts on an ID, it must verify who owns it every time, not just the first time a session touches it.
Never trust client-supplied ownership. A user_id field in a request body is input, not a fact. The only ownership fact the server should trust is the identity attached to the session.
Use indirect references or UUIDs, plus a real ownership check. Unguessable IDs raise the cost of blind enumeration, but they supplement authorization checks, they don't replace one.
Deny by default. New endpoints and roles should start with no access and get permissions added explicitly, rather than starting open with restrictions bolted on after someone notices a gap.
Centralize authorization logic. Scattering if (resource.userId === session.userId) checks across dozens of route handlers means someone eventually forgets one. A shared middleware or policy layer that every data-access path routes through is easier to audit as the codebase grows. That's the same reasoning behind application security best practices more broadly: centralized controls beat checks repeated by hand in every handler.
Access control problems compound at the API layer, since APIs expose raw identifiers that web UIs often hide behind session state. If you're building or reviewing an API, pair this with API security best practices for the broader picture around these endpoints.
The call we'd make#
Treat authorization as a property of the endpoint, not the session. Every handler that accepts an ID should answer "does this belong to the caller" before anything else, and that check belongs in one shared place, not copy-pasted into each route. Fix the missing checks before chasing sequential-ID cleanup, since unguessable IDs without ownership checks are still broken, just slower to break. Then run the two-account test against every ID-accepting endpoint before it ships. This is the one class of vulnerability your scanner won't catch for you.
Get the DevOps Troubleshooting Cheat Sheet
Subscribe and get our free one-page reference for the errors that eat an afternoon — CrashLoopBackOff, OOMKilled, Terraform state locks, and more — plus new guides as we publish them.
Go vs Python Performance: What the Difference Really Is
A practical look at why Go usually outruns Python at runtime, where Python holds its own, and how to pick per workload.
Resizing a Linux Filesystem Live with LVM (No Downtime)
The disk is full and the app is still running. Here is how to grow a logical volume and its filesystem underneath live workloads, safely.
More from DevOps
Explore more articles in this category
Best Managed Kubernetes in 2026: EKS vs GKE vs AKS vs DOKS
The control plane fee is the least interesting number. What separates managed Kubernetes providers is upgrade cadence, how much they run for you, and where the node bill lands.
Best Log Management Tools in 2026: What You Actually Pay For
Every log platform looks affordable at proof-of-concept volume and expensive at production volume. The pricing model, not the feature list, decides which one you can live with.
Your CI Runner Is the Target: Hardening Against npm Worms
The keyv compromise reached 444 packages and over two billion monthly installs through preinstall scripts. The controls that actually stop it are boring and mostly free.
You might have missed
Evergreen posts worth revisiting.