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.

A request crosses the trust boundary from the browser and passes four checks on the server: authenticate (no session gives 401), is there a rule for the route (no rule means refuse, deny by default), does the role allow the function (wrong role gives 403), is the object this user's (not theirs gives 404); only after four yes answers does the handler run
Authentication is the first check, not the last: route, function and object each need their own yes.

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:

CWENameOn this page
CWE-639Authorization Bypass Through User-Controlled Keyorder 1002 (demo 1)
CWE-863Incorrect Authorizationthe user id in the body (demo 2)
CWE-862, CWE-285Missing Authorization; Improper Authorizationthe admin delete (demo 3)
CWE-915Improperly Controlled Modification of Dynamically-Determined Object Attributesrole = admin (demo 4)
CWE-425Direct Request (Forced Browsing)/admin/export.csv (demo 5)
CWE-284Improper Access Controlthe 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.

Two queries on the same orders table with rows 1001 owned by mallory and 1002 owned by alice. Left, WHERE id = 1002 returns alice's row, 200 OK, her address leaks. Right, WHERE id = 1002 AND owner = mallory returns 0 rows, 404 Not Found
Put the owner into the query, and every record that isn't yours looks like one that doesn't exist.

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.

StatusMeaningWhen the fixed app uses it
401 Unauthorizednot authenticatedno session (demo 5)
403 Forbiddenauthenticated, not allowedrole user on an admin route (demo 3)
404 Not Foundno such object for yousomeone else's order (demo 1)
400 Bad Requestthe request asks for something never allowedwriting 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.txt or 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

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.