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.

javascript.javascript

// 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.

javascript.javascript

// 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.