Broken Access Control Testing Guide: Vertical & Horizontal Privesc, Forced Browsing, and Method Tampering
Broken access control sits at the top of the OWASP Top 10 for a reason: it is the most common class of serious web vulnerability, it is rarely caught by signature-based scanners, and the impact ranges from reading another customer's invoice to full administrative takeover. Unlike injection bugs, access control flaws have no malicious payload to fingerprint — the request looks completely legitimate. The only difference between an authorized request and an attack is who is sending it, and the server failed to check.
This guide walks through the techniques that consistently surface access control failures in authorized engagements: horizontal and vertical privilege escalation, forced browsing to hidden functionality, and HTTP method and verb tampering. Everything below assumes you have explicit written authorization to test the target — access control testing is by definition the act of touching data and functions you are not supposed to reach, so scope discipline matters more here than almost anywhere else.
The Core Model: Authentication Is Not Authorization
Authentication answers "who are you?" Authorization answers "are you allowed to do this?" Broken access control is what happens when an application verifies the first and assumes the second. The classic failure is trusting a value the client controls — an ID in the URL, a role flag in a JWT, a hidden form field — instead of deriving the decision from the authenticated session on the server.
Access control failures break down into a few recognizable shapes. Horizontal escalation is accessing peer resources at your own privilege level (another user's account). Vertical escalation is gaining functions reserved for a higher role (a normal user reaching admin endpoints). Context-dependent failures let you perform a valid action in an invalid state — approving your own expense report, for example. Map every test you run to one of these, and you will not miss whole categories.
Setting Up for Access Control Testing
You cannot test authorization with a single account. The minimum viable setup is two same-role accounts plus one higher-privilege account, each in its own browser profile or Burp session so cookies never bleed across them:
# Account matrix for a typical engagement
# A (attacker) role=user id=4012 session=cookieA
# B (victim) role=user id=4013 session=cookieB
# C (admin) role=admin id=1001 session=cookieC
# Test directions:
# A -> B's resources = horizontal privesc
# A -> C's functions = vertical privesc
# (no session) -> any = authentication bypass / forced browsing
The single most effective workflow is the access matrix walk: log in as the high-privilege account, exercise every feature while recording requests, then replay each request with a lower-privilege session and an unauthenticated session. If the response is identical (or merely a different shape of success), you have a finding. Burp's Autorize and ZAP's Access Control add-on automate exactly this swap — they re-issue each proxied request with a configured cookie and diff the responses, flagging anything that doesn't get a clean 401/403.
Horizontal Privilege Escalation
Horizontal escalation is the bread and butter of access control testing, and it overlaps heavily with insecure direct object references. The application uses a client-supplied identifier to fetch a resource but never checks that the resource belongs to the caller. Walk every identifier you can find — path segments, query parameters, JSON body fields, headers, and cookies — and substitute the victim account's value:
# Path parameter — increment, decrement, and jump to low IDs
GET /api/v2/users/4012/profile Cookie: sessionA -> 200 your data
GET /api/v2/users/4013/profile Cookie: sessionA -> 200 victim data (!)
# Query parameter swap
GET /api/invoices?account=4012 Cookie: sessionA -> your invoices
GET /api/invoices?account=4013 Cookie: sessionA -> victim invoices (!)
# Body field swap on a write — higher impact
PATCH /api/v2/users/4013
Cookie: sessionA
{"email":"[email protected]"} -> 200 (account takeover)
Watch for indirect references that look safe. UUIDs and hashes are not authorization controls — if you can obtain a victim's UUID through a separate endpoint (a comment author field, a shared-link token, an autocomplete API), the unpredictability is irrelevant. Likewise, base64 or hex around an integer is encoding, not security; decode it, tamper, re-encode. The Encoder/Decoder handles base64url, hex, and chained transforms so you can manipulate opaque-looking identifiers and feed them straight back into a request.
Don't forget parameter pollution and array smuggling. When an endpoint scopes results to your own ID, try supplying the parameter twice or as an array — frameworks frequently honor the last value or merge them, and the authorization filter may run against the first:
GET /api/orders?user_id=4012&user_id=4013
POST /api/orders {"user_id":["4012","4013"]}
Vertical Privilege Escalation
Vertical escalation targets functionality, not just data. The most reliable approach is to discover the admin surface as an admin (or from leaked client-side code) and then call it as a low-privileged user. Single-page apps are a gift here: the JavaScript bundle often contains the full route table and API map for roles the current user will never see.
# Pull route definitions and endpoints out of the SPA bundle
curl -s https://target.test/static/main.js | grep -oE '/api/[a-zA-Z0-9/_-]+'
# Then call an admin-only endpoint with a plain user session
POST /api/admin/users/1001/promote
Cookie: sessionA
{"role":"admin"} -> if this succeeds, full vertical escalation
Client-side enforcement is a frequent root cause. If the UI merely hides an admin button while the backend still services the request, the control never existed. Equally common is trusting a client-supplied role: a registration or profile-update endpoint that accepts a role, isAdmin, or group field and writes it straight to the database. That is a mass-assignment-flavored vertical escalation:
POST /api/register
{"username":"a","password":"p","role":"admin"}
PATCH /api/users/me
{"isAdmin":true,"plan":"enterprise"}
Also test step-skipping in multi-stage flows. If admin onboarding goes wizard step 1 → 2 → 3 and step 3 grants a privilege, request step 3 directly. State that lives in the URL or a hidden field rather than the server session is almost always bypassable.
Forced Browsing to Hidden Functionality
Forced browsing (sometimes "forceful browsing") is requesting resources that are not linked anywhere — the application relies on obscurity, assuming nobody will guess the path. They will. Combine a good wordlist with the naming conventions you learned from the authenticated UI:
# Content discovery with role-aware wordlists
ffuf -u https://target.test/FUZZ -w admin-paths.txt -mc 200,302,403 \
-H "Cookie: sessionA"
# Common high-value hits to check by hand
/admin /admin/dashboard /actuator/env
/api/internal /debug /metrics
/.git/config /backup.zip /swagger.json
Pay special attention to a 403 that becomes a 200 with a low-privilege cookie attached, and to discovery endpoints that leak the rest of the map — /swagger.json, /openapi.yaml, and GraphQL introspection turn a guessing game into a complete inventory. For API-shaped targets, the API Security Testing Hub organizes the BOLA, BFLA, and mass-assignment checks that pair naturally with forced browsing once you have the endpoint list. Static assets matter too: predictable export paths like /exports/report-2026-06.csv or sequential /uploads/ filenames often expose other tenants' data with no auth at all.
HTTP Method and Verb Tampering
Many access control checks are bolted onto a specific HTTP method — typically the one the UI uses — leaving the others wide open. If GET /admin/user/4013 is blocked, the framework may still route POST, PUT, DELETE, or PATCH to a handler whose authorization filter was only wired for GET. Replay every interesting request across the verb set:
# Verb walk against a protected resource
for M in GET POST PUT PATCH DELETE OPTIONS HEAD; do
printf '%s -> ' "$M"
curl -s -o /dev/null -w '%{http_code}\n' -X "$M" \
-H "Cookie: sessionA" https://target.test/admin/user/4013
done
Two related tricks round this out. HEAD is treated as a GET without a body by many stacks, so a HEAD to a GET-restricted endpoint sometimes slips through filters that only match the literal string "GET". And method override headers let you smuggle a blocked verb inside an allowed one — frameworks that honor these will dispatch to the override target while a front-end gateway only sees the outer POST:
POST /admin/user/4013 HTTP/1.1
X-HTTP-Method-Override: DELETE
X-HTTP-Method: DELETE
X-Method-Override: PUT
Case and path normalization mismatches feed the same class of bug: a gateway rule on /admin that fails to match /Admin, /admin/, /admin/., or /%61dmin while the backend resolves all of them to the same handler. Always test the normalized variants of any path the front-end claims to protect.
Remediation and Defenses
Every reliable fix for broken access control shares one principle: deny by default and enforce on the server, per request, against the authenticated session — never against a client-supplied identifier. Concretely:
- Derive identity from the session, not the request. Resolve the acting user from the verified session/token, then scope every query to it:
SELECT * FROM invoices WHERE id = ? AND owner_id = :sessionUserId. Ownership becomes a query condition, not an afterthought. - Centralize authorization. Put checks in a single middleware or policy layer (RBAC/ABAC) rather than scattering
if (user.isAdmin)across controllers. A central enforcement point is auditable; scattered checks rot the moment someone adds a route. - Default-deny routing. New endpoints should require an explicit grant to be reachable. If a developer forgets to annotate a route, it should 403, not 200.
- Make access control method-agnostic. Apply the policy to the resource and action, not to a single verb or a literal path string. Normalize the path and method before the decision so
/Admin,POST-override, and trailing-slash variants all hit the same check. - Reject client-supplied authority fields. Use allow-lists for writable attributes so
role,isAdmin,owner_id, and pricing/plan fields can never be set from a request body. - Use unpredictable references where it cheaply helps, but never as the control. Random UUIDs raise the cost of blind enumeration, yet the server must still verify ownership on every fetch.
- Log and alert on authorization failures. A spike of 403s from one session is a near-perfect signal of an enumeration attempt — rate-limit and alert on it.
On the testing side, bake regression coverage in: write an automated suite that replays a representative request set as user, peer-user, and unauthenticated, asserting 403/401 where appropriate. Access control bugs reappear with every new feature, so a CI guard that walks the access matrix catches the regression long before it ships.
To go deeper on the identifier-substitution mechanics that underpin horizontal escalation, the IDOR Cheat Sheet and the IDOR Generator give you copy-ready test cases for enumerating and tampering with object references across paths, parameters, and JSON bodies — the exact muscle you will exercise on every access control engagement.
Put this into practice
Generate and test these payloads interactively — free, in your browser.
Level up your security testing
Install the CLI
npx payload-playgroundExplore All Tools
Encoding, hashing, JWT & more
Browse Cheat Sheets
Quick-reference payload guides