Chapter 45 — Broken access control
In one sentence
Access control is enforcing who may do what to which object — and it's OWASP's #1 category because it must be checked on every request to every object, server-side, and one missed check is a breach.
The failure modes
IDOR / BOLA (Broken Object-Level Authorization)
GET /api/orders/1001 -> your order
GET /api/orders/1002 -> someone else's order, returned happily
The app checks you're LOGGED IN but not that the object is YOURS.
#1 API vulnerability (OWASP API Top 10 A01). Also with UUIDs if they
leak; obscurity is not authorization.
BROKEN FUNCTION-LEVEL AUTHORIZATION (BFLA)
POST /api/admin/users/5/promote -> works for a normal user because
the endpoint only checks authentication, or hides the button but not
the route ("security by missing link").
VERTICAL escalation: user -> admin.
HORIZONTAL escalation: user A -> user B's data.
MASS ASSIGNMENT / auto-binding
PATCH /api/users/me {"name":"x","role":"admin","credits":99999}
The app binds the whole JSON to the model; you set fields you
shouldn't. Fix: explicit allowlist of updatable fields (DTOs).
PATH TRAVERSAL
GET /download?file=../../../../etc/passwd
GET /download?file=..%2f..%2f (encoded) / ....// (filter bypass)
Fix: resolve to a canonical absolute path, assert it's inside the
intended base dir; better, use an opaque ID -> lookup, never a path.
MISSING RE-CHECK ON SECOND STEP
Multi-step flows (checkout, wizards) that authorize step 1 and trust
hidden fields / prior state on step 3. Re-derive and re-check server-
side at every step.
METADATA / SIDE-CHANNEL access
Referer leaks, predictable exports, GraphQL introspection,
debug endpoints, .git/ , /actuator, backup files, verbose errors.
The principles that prevent it
1. DENY BY DEFAULT. Every route/handler requires an explicit allow.
A new endpoint with no annotation must be UNREACHABLE, not public.
2. ENFORCE SERVER-SIDE, on every request. The UI hiding a button is
not access control. The client is attacker-controlled.
3. CHECK THE OBJECT, not just the verb. "Can this user read THIS
order?" -- scope every query by the owner:
SELECT * FROM orders WHERE id = ? AND owner_id = :current_user
(make ownership a WHERE clause, not an afterthought).
4. CENTRALISE the decision. One authorization layer/middleware/policy
engine (OPA, Cedar, Oso, your framework's guards), not ad-hoc `if`
checks scattered per controller.
5. USE OPAQUE, UNGUESSABLE IDs where you can (UUIDv4), but treat that
as defence in depth -- still check ownership.
6. LEAST PRIVILEGE everywhere: roles, scopes, DB grants, cloud IAM,
service accounts. Time-box elevated access.
7. LOG access-control decisions, especially denials, and alert on
spikes (enumeration).
8. TEST IT: for every endpoint, automated tests for "as another user",
"as anonymous", "as lower role".
The SecureShop callback
Chapter 4 asked you to record your instinct for "per-customer authz" and "price-tampering". Here's the concrete version:
GET /api/orders/:id
BAD: if (!session.user) return 401; return db.order(id)
GOOD: if (!session.user) return 401;
order = db.query("SELECT * FROM orders WHERE id=? AND customer_id=?",
[id, session.user.id])
if (!order) return 404 // 404, not 403 -> don't confirm existence
POST /api/checkout
BAD: total = req.body.total // client sends the price!
GOOD: recompute total server-side from cart items x current DB prices;
ignore any client-supplied price/total/discount entirely.
Validate the coupon server-side. (This is A01 + insecure design.)
Practice (40 min) — lab only
1. IDOR: in Juice Shop, view your basket, then change the basket ID in
the request to another value. Retrieve someone else's basket.
Then find the "view another user's order" variant.
2. BFLA: find an admin-only API call (watch the admin UI in devtools).
Call it directly as a normal user.
3. Mass assignment: register or update a profile and add "role":"admin"
(or "isAdmin":true) to the JSON body. Check if it sticks.
4. Path traversal: find a file-serving endpoint; try ../ and encoded
variants to escape the intended directory.
5. FIX locally: build a 3-endpoint app (list my orders, get order by id,
update profile). Introduce each bug, exploit it, then fix with:
ownership in the WHERE clause; a role guard middleware; a field
allowlist DTO; canonical-path containment check. Add tests that call
each endpoint "as user B" and expect 404/403.
Common confusions
- "We use UUIDs, so no IDOR." Unguessable != authorized. UUIDs leak (URLs, Referer, logs, other endpoints). Still check ownership.
- "The frontend only shows admins the button." The API is the boundary. Attackers call it directly.
- "Role check at the top of the controller is enough." Object-level checks are separate: right role, wrong object. Scope every data access by owner/tenant.
- "Return 403 for someone else's object." That confirms the object exists (enumeration). Prefer 404 for "not yours".
- "We'll add authz tests later." Access control is the #1 breach cause and the easiest to regress silently. Test from day one.
Check yourself
- What is IDOR/BOLA, and why do UUIDs not fully fix it?
- Difference between vertical and horizontal privilege escalation.
- What is mass assignment and the fix?
- Why enforce access control server-side on every request, and why centralise it?
- In
GET /api/orders/:id, show the query that makes ownership part of the check. Why return 404 rather than 403? - Why must
POST /checkoutnever trust a client-supplied total?
Further reading
- Cheat sheets: OWASP "Authorization", "Access Control", "Insecure Direct Object Reference Prevention", "Mass Assignment".
- List: OWASP API Security Top 10 — API1:BOLA and API5:BFLA are this chapter; the whole list is essential for anyone building APIs.
- Course: PortSwigger Academy — "Access control vulnerabilities" track.
- Tools: Authz policy engines — OPA/Rego, AWS Cedar, Oso, Casbin — read one's intro to see what "centralised" looks like.
- Talk: "The Bug Hunter's Methodology" (Jason Haddix) sections on access control and IDOR hunting.