Horizontal Privilege Escalation: Cross-User Resource Access via Insufficient IDOR Controls
Theory
Why This Matters
Horizontal privilege escalation — accessing another user's data without elevating one's role — is among the most frequently exploited real-world vulnerabilities. The 2019 Facebook IDOR vulnerability allowed attackers to view private videos belonging to any user simply by manipulating a numeric parameter in a video-download API call. In 2022, a researcher earned a $100,000 bounty on HackerOne by demonstrating that a fintech platform's /api/accounts/{id}/transactions endpoint returned another customer's complete transaction history when the account ID was substituted. OWASP API Security Top 10 (2023) ranks Broken Object Level Authorization (BOLA/IDOR) as API1 — the single most critical API security risk — precisely because it is systemic: every object the application manages is a potential boundary violation if server-side ownership checks are absent.
Core Concept
Horizontal privilege escalation occurs when an authenticated user accesses resources belonging to another user at the same privilege level. The mechanism is almost always an Insecure Direct Object Reference (IDOR): the application exposes a direct identifier (user ID, account number, document ID) in the URL, request body, or cookie, and the server performs the database lookup without first verifying that the requesting user owns or is permitted to access the referenced object.
The violated invariant is object ownership enforcement: every read or write on a user-scoped resource must validate that session.user_id == resource.owner_id (or an equivalent authorization model) before returning data. When this check is absent, the only barrier to cross-user access is the client's ability to guess or enumerate the identifier — a trivially low bar for sequential integers and often achievable even for GUIDs if they leak via other channels.
It is important to distinguish horizontal from vertical privilege escalation: horizontal means same role, different user's data; vertical means escalating to a higher-privileged role (e.g., user → admin). Both represent broken access control, but the exploitation techniques, impact scope, and remediation strategies differ.
API endpoint enumeration systematically maps the full set of object-scoped endpoints (GET /invoices/{id}, GET /messages/{id}, PATCH /profile/{user_id}) to identify every surface where an IDOR could exist. The attacker creates two accounts in the target application and uses each account's session token to request objects belonging to the other, confirming the absence of ownership checks.
Technical Deep-Dive
-- Account A (attacker): authenticate and obtain session token
POST /api/auth/login HTTP/1.1
Host: victim.com
Content-Type: application/json
{"username":"attacker","password":"P@ssw0rd"}
HTTP/1.1 200 OK
Set-Cookie: session=SESSION_A; HttpOnly; Secure
-- Account A's own document:
GET /api/documents/1042 HTTP/1.1
Host: victim.com
Cookie: session=SESSION_A
HTTP/1.1 200 OK
{"id":1042,"owner_id":77,"content":"Attacker's own document"}
-- Horizontal escalation: substitute Account B's document ID
GET /api/documents/1041 HTTP/1.1
Host: victim.com
Cookie: session=SESSION_A
HTTP/1.1 200 OK <-- Should be 403 Forbidden
{"id":1041,"owner_id":76,"content":"Victim B's confidential document"}
# Burp Intruder payload — enumerate document IDs around a known value
# Positions: GET /api/documents/§1042§
# Payload type: Numbers, from 1000 to 1100, step 1
# Grep for: "owner_id" != "77" to flag cross-user responses
import requests
session_a = "SESSION_A"
my_owner_id = 77
for doc_id in range(1000, 1100):
r = requests.get(
f"https://victim.com/api/documents/{doc_id}",
cookies={"session": session_a}
)
if r.status_code == 200:
data = r.json()
if data.get("owner_id") != my_owner_id:
print(f"IDOR: doc {doc_id} belongs to user {data['owner_id']}")
Security Assessment Methodology
- Create two test accounts (Account A and Account B) in the target application. Record the session tokens for both.
- Map object-scoped API endpoints — every endpoint with a variable identifier in the path or body is a candidate (
/users/{id},/orders/{id},/messages/{thread_id}). - As Account A, request Account B's objects — substitute Account B's known resource IDs into Account A's authenticated requests. A 200 response with Account B's data confirms the IDOR.
- Use Burp Intruder to enumerate IDs numerically around a known good value. Filter responses by HTTP status code and response length to identify valid cross-user objects.
- Test all HTTP verbs — IDOR may exist on GET but not on PATCH, or vice versa. Test read, update, and delete operations separately.
- Install Burp Autorize plugin — configure it with Account B's session token and browse the application as Account A; Autorize automatically replays every request with Account B's token and highlights responses that differ from the unauthenticated baseline.
- Check indirect references — some applications use hashed or GUID-style IDs that map server-side to sequential integers; discover the mapping by leaking IDs from other endpoints (emails, logs, notifications).
Defensive Countermeasure — Every server-side handler for an object-scoped operation must retrieve the resource and verify
resource.owner_id == authenticated_user.idbefore returning or modifying data. This check must be implemented in the service/repository layer, not only in the UI. Use a centralized authorization library (e.g., OPA, Casbin) so ownership checks cannot be forgotten on individual endpoints.
Common Assessment Errors
- Confusing 401 with 403 — a 401 means unauthenticated; a 403 means authenticated but forbidden. Horizontal IDOR testing requires an authenticated session; test with a valid cookie, not without one.
-
Testing only GET — many IDOR write-ups focus on read access; update (PATCH/PUT) and delete (DELETE) IDORs are equally severe and often overlooked.
-
Missing POST body parameters — IDs embedded in JSON request bodies (
{"account_id": 123}) are as exploitable as URL parameters but often missed by automated scanners. - Ignoring response-code-only analysis — an endpoint may return 200 with an empty body for unauthorized objects rather than a 403; always inspect the response body, not just the status code.
- Not testing pagination and filtering parameters —
?user_id=77&page=2in a list endpoint is an IDOR where the user_id parameter is the control bypass target. - Assuming GUIDs are safe — UUID v4 is random, but UUID v1 is timestamp-based and predictable; and even random GUIDs leak via emails, URLs in responses, or access logs.
NICE Framework Alignment
| Code | Knowledge/Skill/Task Statement | How This Card Develops It |
|---|---|---|
| K0007 | Knowledge of authentication, authorization, and access control methods | Explains object-level authorization as a distinct control from authentication, and why ownership checks must be server-side |
| K0065 | Knowledge of policy-based and attribute-based access control | Covers the ownership attribute check model and how its absence enables cross-user data access |
| S0001 | Skill in conducting vulnerability scans and recognizing vulnerabilities in security systems | Practises two-account IDOR testing methodology with Burp Intruder and Autorize |
| T0028 | Task: Identify systemic security issues based on vulnerability and configuration data | Builds methodology for identifying IDOR as a systemic access-control failure across all object-scoped endpoints |
Further Reading
- OWASP API Security Top 10 — API1:2023 Broken Object Level Authorization — OWASP Foundation
- Hacking APIs: Breaking Web Application Programming Interfaces — Corey Ball, No Starch Press (2022)
- IDOR: A Deep Dive into Broken Object Level Authorization — PortSwigger Web Security Academy
- The Bug Hunter's Methodology v4 — Jason Haddix (conference presentation)
Challenge Lab
Reinforce your learning with a hands-on generated challenge based on this card's competency.