IDOR and Broken Access Control: Finding and Fixing the Most Common Web Flaw
What IDOR and broken access control are, why they dominate real-world findings, how to test for them in authorized assessments, and how to enforce authorization correctly.
An invoice page loads at /invoices/4821. The user is curious, or a scanner is thorough, and the next request is for /invoices/4822. It returns someone else’s invoice. No exploit, no injection, no special tooling, just a number incremented by one. This is an insecure direct object reference, and the broader category it belongs to, broken access control, is consistently at or near the top of the OWASP Top 10. It stays there because it is not a bug in a library. It is a missing decision in your own code, and scanners are poor at finding it because only your application knows who should see what.
This article is for developers who need to prevent it and for testers who need to find it in systems they own or are authorized to assess.
Authentication Is Not Authorization
Two questions get conflated:
- Authentication: who are you?
- Authorization: are you allowed to do this, to this object, right now?
Most applications answer the first correctly and the second inconsistently. A user is logged in, so a request proceeds, and the handler fetches whatever record the request names. Access control means every request is checked against a policy, on the server, for the specific resource and action involved.
Broken access control shows up in several shapes:
| Type | Example |
|---|---|
| Horizontal (IDOR) | User A reads or changes user B’s order by changing an ID |
| Vertical | A normal user calls an admin endpoint such as /admin/users and it works |
| Function-level | The UI hides a button, but the API behind it has no role check |
| Mass assignment | A profile update accepts "role": "admin" in the body |
| Context-dependent | A user edits an order after it was shipped, or approves their own expense |
| Multi-tenant leakage | A query forgets the tenant filter and returns another organization’s rows |
Why It Is So Common
Several design habits create the flaw:
- Authorization lives in the UI. Hiding a link is not a control. Anyone can send the request directly.
- Checks are per-endpoint and hand-written. With hundreds of routes, one developer forgets one check, and nothing fails visibly.
- Predictable identifiers make exploitation trivial, though they are not the root cause.
- Frameworks fetch by ID by default.
Model.find(id)returns the record regardless of who asked. - Indirect paths are forgotten: export endpoints, PDF generators, webhooks, GraphQL resolvers, file download URLs, background job triggers and search APIs that skip the checks the main CRUD path has.
- Testing is done as one user. Functional tests prove the owner can use the feature, not that a stranger cannot.
What a Vulnerable Handler Looks Like
// Vulnerable: authenticated, but never checks ownership
app.get("/api/invoices/:id", requireLogin, async (req, res) => {
const invoice = await db.invoices.findById(req.params.id);
if (!invoice) return res.status(404).end();
res.json(invoice);
});
The handler confirms a session exists, then hands over any invoice. The fix is to make ownership part of the query, so the data layer cannot return what the caller may not see:
// Safer: the query is scoped to the authenticated principal
app.get("/api/invoices/:id", requireLogin, async (req, res) => {
const invoice = await db.invoices.findOne({
id: req.params.id,
accountId: req.user.accountId,
});
if (!invoice) return res.status(404).end(); // same response for missing and forbidden
res.json(invoice);
});
Returning 404 for both “does not exist” and “not yours” avoids confirming which identifiers are valid. Returning 403 is acceptable when existence is not sensitive, but be consistent.
Finding It: A Methodology for Authorized Testing
Only run these tests against applications you own or have explicit written permission to assess, ideally in a staging environment with synthetic accounts. Never use real customer data.
1. Build an access matrix
List roles (anonymous, regular user, manager, admin) and, for multi-tenant systems, at least two separate tenants. Create two test accounts per role. Then enumerate resources and actions: read, create, update, delete, export, share.
2. Map every object reference
Walk the application as one user with an intercepting proxy running, and record every place an object is named:
- Path segments:
/orders/123 - Query parameters:
?user_id=7 - Request bodies:
{"project": 55} - Headers and cookies carrying identifiers
- Signed or encoded values such as base64 IDs, which are often just obfuscation
- GraphQL arguments and node IDs
- File and download URLs
3. Replay with another identity
The core test is simple: take a request made by user A, replace the credentials with user B’s, and see if it still succeeds. Repeat for each role pair. Proxy extensions can automate this by replaying every captured request under a second session and diffing responses, but the interesting results still need human judgment.
# As user B, request user A's resource. Expect 403 or 404.
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer $TOKEN_USER_B" \
https://staging.example.com/api/invoices/4821
4. Test the less obvious paths
- Change the HTTP method: a
GETmay be protected whilePUTorDELETEon the same URL is not. - Call admin or internal endpoints discovered in JavaScript bundles, API documentation or error messages, using a low-privilege token.
- Add fields to update requests (
role,isAdmin,tenantId,price) to probe mass assignment. - Try the same object through a different feature: if the invoice page is protected, check the PDF export, the email-me-a-copy action and the audit log view.
- Test unauthenticated access to the same URLs, since some routes lack even a login requirement.
- Check state and workflow rules, such as acting on an object after it has been approved or archived.
5. Check indirect and bulk exposure
- Listing and search endpoints that return more than the user should see, or accept a filter parameter that widens the scope.
- Predictable file names in object storage, and public URLs that never expire.
- Sequential IDs that let a single compromised permission turn into full enumeration.
Record each finding with the request pair, the expected result and the actual one. That evidence is what lets a developer write a regression test.
Fixing It: Design for Enforcement
Spot fixes help, but the durable answer is structural.
Deny by default
A new route should reject everyone until a policy says otherwise. Attach a policy requirement to every route, and make it a startup error if a route has none.
# Pseudo-code for a framework-level guard
def require(policy):
def wrap(handler):
handler.policy = policy
return handler
return wrap
def register(app, route, handler):
if not hasattr(handler, "policy"):
raise RuntimeError(f"{route} has no authorization policy")
app.add_route(route, handler)
Centralize the decision
Put authorization logic in one place that every path calls: a policy layer, middleware or a library, rather than scattered if statements. Policies read like rules:
def can_view_invoice(user, invoice) -> bool:
if user.role == "admin":
return True
return invoice.account_id == user.account_id and "billing:read" in user.permissions
Scope queries by the principal
For multi-tenant data, enforce the tenant filter below the application code. In PostgreSQL, row-level security does exactly this and protects you even when a developer forgets the WHERE:
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (account_id = current_setting('app.account_id')::bigint);
-- Per request, set the tenant on the connection or transaction:
-- SET LOCAL app.account_id = '42';
Make sure the application’s database role is not the table owner or a superuser, since those bypass row-level security unless it is forced.
Never trust client-supplied authority
Derive the user, tenant and role from the server-side session or verified token. Ignore a userId or role in the request body. For updates, use an explicit allowlist of writable fields rather than binding the whole payload to the model.
const ALLOWED = ["displayName", "timezone", "marketingOptIn"];
const update = Object.fromEntries(
Object.entries(req.body).filter(([k]) => ALLOWED.includes(k))
);
await db.users.update(req.user.id, update);
Use unpredictable identifiers as defense in depth
UUIDs or random IDs make enumeration impractical and reduce accidental exposure, but they are not authorization. A leaked UUID in a shared link, log or referrer works for anyone who has it. Always check ownership as well. Where a link must be shareable, use a purpose-specific, expiring, revocable token rather than the internal ID.
Protect files and exports
Serve downloads through an authenticated handler that checks the policy, or use short-lived signed URLs generated after an authorization check. Avoid public buckets for user content.
Handle JWT and API specifics
Claims such as role or tenant in a token are only trustworthy if the token is validated correctly and the claims were set server-side (see JWT security). For APIs, enforce object-level and function-level checks on every endpoint, including those used only by your own frontend. Related OAuth pitfalls such as overly broad scopes are covered in API security and OAuth vulnerabilities.
Make It Testable and Observable
Automated tests. Write authorization tests as a matrix, not as happy paths. A parametrized test that iterates roles and endpoints catches regressions when someone adds a route.
import pytest
CASES = [
("GET", "/api/invoices/{other_id}", "user_a", 404),
("GET", "/api/invoices/{own_id}", "user_a", 200),
("DELETE", "/api/invoices/{own_id}", "viewer", 403),
("GET", "/admin/users", "user_a", 403),
("GET", "/admin/users", "admin", 200),
]
@pytest.mark.parametrize("method,path,actor,expected", CASES)
def test_access_matrix(client, fixtures, method, path, actor, expected):
resp = client.request(method, path.format(**fixtures.ids), headers=fixtures.auth(actor))
assert resp.status_code == expected
Logging and detection. Log every authorization denial with the user, resource and route. A single user producing many 403 and 404 responses across sequential IDs is a clear enumeration signature, and you can alert on it with rate and pattern rules. Combine that with rate limiting on object lookups, which slows down both abuse and honest mistakes. A web application firewall will not reliably catch this class, because each request looks legitimate on its own (see web application firewalls for what they can and cannot do).
Code review. Add a review question for every new endpoint: which principal, which object, which policy? It is a cheap habit that prevents the most common omission.
When Obfuscation and Shortcuts Fail
- Encoding or hashing an ID is not access control. Base64 decodes in one line.
- “Security through the frontend” fails the moment someone uses a proxy or
curl. - A role check on the page but not the API leaves the real door open.
- Filtering results in application code after fetching everything is fragile and slow; scope the query instead.
- Relying on a gateway for authorization works only if every service validates independently as well. Internal services that trust any caller become the next target after a perimeter slip.
Key Takeaways
- Broken access control is a missing server-side decision, not a library bug. Authenticated does not mean authorized.
- Scope every query by the authenticated principal, return the same response for missing and forbidden objects, and take identity and role from the server, never the request body.
- Deny by default, centralize policy and fail at startup if a route has no policy attached.
- Test with at least two accounts per role and two tenants, replay requests across identities, and check alternate methods, exports and bulk endpoints.
- Use row-level security, unpredictable identifiers and signed expiring links as extra layers, never as a replacement for ownership checks.
- Log denials and alert on sequential-ID enumeration, and keep an authorization test matrix in CI so regressions are caught before release.