The idea: logged in is not the same as allowed
Broken access control is item A01 of the OWASP Top 10:2025, the most common risk on the list. Authentication answers "who is this?"; access control (authorization) answers "may this user do this to this object?". Both apps on this page authenticate every request the same way: they look up mallory's session cookie and know she is mallory, role user. Only the fixed app then asks the second question, on the server, for every request.
Everything left of the trust boundary is under the attacker's control: hidden buttons, unlinked URLs, ids in the URL, fields in the JSON. Each attack on the page sends one or two ordinary requests and runs against the vulnerable app, then against the fixed app, which enforces one more access rule. The rules panel shows which rule is missing (✗) or enforced (✓), and the session of the request with the access decision.
OWASP maps A01 to many CWEs. The ones the page shows:
| CWE | Name | On this page |
|---|---|---|
| CWE-639 | Authorization Bypass Through User-Controlled Key | order 1002 (demo 1) |
| CWE-863 | Incorrect Authorization | the user id in the body (demo 2) |
| CWE-862, CWE-285 | Missing Authorization; Improper Authorization | the admin delete (demo 3) |
| CWE-915 | Improperly Controlled Modification of Dynamically-Determined Object Attributes | role = admin (demo 4) |
| CWE-425 | Direct Request (Forced Browsing) | /admin/export.csv (demo 5) |
| CWE-284 | Improper Access Control | the parent of all of them |
Reading the canvas: the client (alice, the customer, and mallory, the attacker) is on the left with the full request, the response and mallory's notes. The server side is on the right: the app with its two-line handler, the database tables the handler touches, the access rules and the server log. Boxes fly for every hop: blue is an HTTP request, orange an SQL statement, grey the rows that come back, green a 2xx reply and red a 4xx reply. The database is reset to the attack's starting rows at the start of each run.
IDOR: the id in the URL picks the row
mallory's own order is /api/orders/1001. She changes the number to 1002 (Demo 1: IDOR). The vulnerable handler runs SELECT * FROM orders WHERE id = 1002 and returns alice's order and address. An insecure direct object reference is any place where a key from the request (an id, a file name, an account number) selects data and nothing checks that the caller may see it. Ids that count up make it trivial to loop over every record; random UUIDs make guessing harder but are not a check.
The fix is a server-side ownership check on every object access, best written into the query itself: WHERE id = ? AND owner = session.user. It returns 0 rows, and the app answers 404 Not Found, the same as for an id that does not exist, so the attacker learns nothing. (A 403 is also correct, but it confirms that 1002 exists.)
Ownership comes from the session
The settings page sends {"user_id":"alice","email":"evil@m.io"} (Demo 2). The vulnerable handler updates WHERE user = body.user_id, so alice's email becomes mallory's, and "forgot password" on alice now mails the reset link to mallory: an account takeover. The fixed handler ignores user_id and updates WHERE user = session.user. The request still succeeds, but it can only change the caller's own row. The rule: who is acting always comes from the server's session (or a verified token, see JWT vs Session Cookies), never from a field the client sends.
Missing function-level checks
The admin panel hides the Delete button from normal users, but the page's JavaScript names the API: DELETE /admin/users/:name. mallory calls it herself (Demo 3), and the vulnerable handler deletes alice's account. Hiding a control in the UI is a usability choice, not a check. The fixed handler tests session.role == 'admin' before any SQL and answers 403 Forbidden. In practice the check sits in one place that every admin route passes through: a middleware, a route guard or a policy decorator, so a new route cannot forget it.
Mass assignment: escalating to admin
The profile API returns mallory's user object with a role field. She sends it back with "role":"admin" (Demo 4). The vulnerable handler copies every field of the body onto the user, as Object.assign or a framework's automatic binding does, so mallory is now an admin and every function-level check says yes to her. The fixed handler has an allow-list of writable fields, [email], and refuses anything else with 400 Bad Request. Roles change only through an admin function that checks its caller.
Forced browsing and deny by default
robots.txt tells search engines not to index /admin/export.csv, which tells everyone else that it exists (Demo 5). mallory requests it with no cookie at all. The vulnerable route has no access rule, because its author assumed nobody would find an unlinked URL, and it sends all 1,240 customers. That is failing open: a missing check means "allow".
The fixed app denies by default. The router refuses every request unless a rule allows it; the rule for /admin/* needs an admin session. With no session the answer is 401 Unauthorized (a logged-in non-admin would get 403). A forgotten rule now fails closed: the route is unreachable until someone writes the rule, which is a bug report, not a breach.
| Status | Meaning | When the fixed app uses it |
|---|---|---|
401 Unauthorized | not authenticated | no session (demo 5) |
403 Forbidden | authenticated, not allowed | role user on an admin route (demo 3) |
404 Not Found | no such object for you | someone else's order (demo 1) |
400 Bad Request | the request asks for something never allowed | writing role (demo 4) |
How to prevent it
- Deny by default: except for public resources, every route refuses unless a rule allows the caller.
- Enforce access control on the server, in one reusable place (middleware, policy layer), never only in the UI.
- Check ownership on every object access: scope queries to the session's user or tenant, for reads, updates and deletes alike.
- Take the acting user, tenant and role from the session or a verified token, never from the request body, URL or a cookie the client can edit.
- Allow-list the fields an endpoint may write; use separate input objects (DTOs) instead of binding requests onto database models.
- Don't rely on hidden URLs,
robots.txtor unguessable ids; disable directory listing and keep backups and metadata (.git) out of the web root. - Log access-control failures and alert on repeats; rate-limit APIs to slow automated scraping of ids.
- Write tests for every role against every route ("a user gets 403 on /admin/*", "alice gets 404 on bob's order") so a missing check fails the build.
OWASP Top 10:2025 series
- A01 Broken Access Control (this page)
- A02 Security Misconfiguration
- A03 Software Supply Chain Failures
- A04 Cryptographic Failures
- A05 Injection
- A06 Insecure Design
- A07 Authentication Failures
- A08 Software or Data Integrity Failures
- A09 Security Logging and Alerting Failures
- A10 Mishandling of Exceptional Conditions
See also OWASP A07: Authentication Failures (the "who is it?" step this page takes for granted), How OAuth 2.0 Works (scopes: access control for delegated tokens) and Server-Side Request Forgery, which OWASP 2025 counts under A01: the server reaching what the user cannot.
What the page leaves out
- Other A01 cases: path traversal (
../../etc/passwd), a CORS policy that trusts any origin, CSRF, open redirects, and tampering with a JWT or cookie that carries the role. - Access-control models: role-based (RBAC), attribute-based (ABAC) and relationship-based (ReBAC) policies, policy engines such as OPA, and multi-tenant isolation.
- Horizontal vs vertical escalation as a vocabulary: demos 1 and 2 are horizontal (another user, same role), demos 3 and 4 vertical (a higher role).
- Real sessions and passwords: the session lookup is one query, and the role is read from the users table on every request.
- Logging, alerting and rate limiting that would notice mallory looping over order ids.