Access control bugs are rarely subtle at the model level. They come from trusting the wrong thing.
The 13 PortSwigger access control labs make the pattern clear:
Authorization must be enforced by the server on the resource and action being performed.
Everything else is a hint, not an authority.
The map
| Trust mistake | Example |
|---|---|
| hidden route | admin path in robots.txt or JavaScript |
| client role | Admin=true cookie |
| mass assignment | JSON body accepts roleid: 2 |
| object identifier | id=carlos |
| unpredictable identifier | GUID leaked elsewhere |
| redirect behavior | 302 body still contains account data |
| method split | POST blocked, GET allowed |
| workflow split | confirmation step lacks auth |
| header trust | Referer treated as permission |
Hidden is not authorized
The first labs expose admin functionality through robots.txt or JavaScript. The admin route may be obscure, but it is still unauthenticated.
This distinction matters in real systems. Discovery controls can reduce noise. They cannot decide whether a user may delete an account.
The client cannot assign its role
Two labs trust client-controlled privilege state:
Admin=true
and:
{"roleid": 2}
Both are the same class of bug. The client is allowed to define a server-side authorization fact.
The fix is not “hide the field.” The fix is DTO allowlisting and server-side privilege state.
IDOR is about ownership
The basic horizontal escalation is:
/my-account?id=carlos
The GUID version only changes enumeration. Carlos’s GUID appears in a blog author link, so the account endpoint is still missing an ownership check.
Redirect leakage is another version of the same mistake. The app sends a 302 away from Carlos’s page, but the 302 body still contains his API key. Automatic redirect following can hide the vulnerable response during manual testing.
The transcript lab reduces this to a filesystem object reference: 1.txt contains another user’s chat log and password.
The invariant is owner/resource/action. Every object access needs it.
Routes, methods, and workflows must agree
Front-end URL blocking fails when the back end honors a different route source:
GET /?username=carlos HTTP/1.1
X-Original-URL: /admin/delete
The front end authorizes /; the back end executes /admin/delete.
Method-based checks fail when POST is protected but GET reaches the state-changing action:
GET /admin-roles?username=wiener&action=upgrade
Multi-step workflows fail when only the first step checks admin rights. Referer-based checks fail because Referer is just a request header.
Defender notes
Hardening:
- enforce authorization on every sensitive server-side handler;
- base decisions on server-side session and permission models;
- never trust client-supplied role, owner, admin, or user ID fields;
- use DTO allowlists for profile/account updates;
- check subject, action, and resource for every object access;
- treat GUIDs as identifiers, not permissions;
- avoid sensitive data in redirect bodies and error responses;
- validate every step in multi-step workflows;
- apply the same authorization policy across methods;
- disable or tightly constrain route override headers;
- never use Referer as authorization.
Detection:
- normal users requesting
/admin,/administrator-panel,/admin-roles, or delete routes; - client requests containing
Admin=true,roleid,isAdmin, or unexpected owner fields; - one session iterating user IDs, GUIDs, filenames, or transcript numbers;
- 302 responses containing API keys, passwords, or account details;
- state-changing GET requests;
- non-admin sessions carrying admin Referer headers;
X-Original-URLand similar override headers.
The practical rule is simple: authorize the final action where it happens. Do not outsource authorization to route obscurity, browser behavior, request headers, or client-controlled state.