Broken Access Control is the #1 vulnerability class in the OWASP Top 10 (2021). IDOR is its most commonly reported subtype in bug bounty. This chapter covers the full attack surface: every IDOR variant, every access control bypass pattern, ID format analysis, the two-account methodology, mass assignment, GraphQL IDOR, and what a paid report looks like.
Access control enforces which users can perform which actions on which resources. Every request to a protected resource must answer three separate questions:
| Question | Mechanism | Where It Fails |
|---|---|---|
| Are you who you say you are? | Authentication — login systems, session tokens, MFA | Credential theft, session hijacking |
| Are you allowed to do this action? | Authorization — role checks, privilege levels | Vertical privilege escalation, forced browsing |
| Do you own this specific object? | Resource ownership — per-object authorization checks | IDOR — the most common failure |
The critical insight: authentication and authorization are separate checks. An application can perfectly verify that a user is logged in (authentication) while completely failing to check whether that logged-in user is allowed to touch the specific record they requested (authorization). This is the root cause of IDOR.
Broken Access Control moved to the #1 position in the OWASP Top 10 in 2021, overtaking injection. It is the single most prevalent web vulnerability class by real-world occurrence. In bug bounty programs, IDOR findings consistently make up the highest volume of valid, paid reports — particularly because they require no special tooling, just careful observation and the right methodology.
IDOR (Insecure Direct Object Reference) occurs when an application uses a user-supplied identifier to look up an internal object and fails to verify that the requesting user is authorized to access that object.
The canonical IDOR pattern// Authenticated as User A (session cookie = Alice's JWT)
GET /api/invoices/1001 → 200 OK — Alice's invoice ✓
GET /api/invoices/1002 → 200 OK — Bob's invoice ✗ ← IDOR
// The server asked: "Is Alice logged in?" → YES → return record 1002
// The server never asked: "Does Alice OWN record 1002?"
// Vulnerable backend pseudocode:
if session.is_authenticated:
return db.orders.get(id=request.id) ← ownership never checked
// Secure backend pseudocode:
if session.is_authenticated:
return db.orders.get(id=request.id, user_id=session.user_id)
/api/admin/users — full user database/api/admin/delete-user/42 — delete any accountMost hunters test GET requests only. Write-operation IDOR is far more impactful and far less tested:
| Method | Example | Impact |
|---|---|---|
PATCH / PUT |
PATCH /api/user/43 {"email":"attacker@evil.com"} |
Critical Change victim's email → trigger password reset → account takeover |
DELETE |
DELETE /api/posts/1002 |
High Destroy another user's data or account |
POST |
POST /api/user/43/message {"content":"..."} |
Med Create resources under another user's identity |
GET |
GET /api/user/43/profile |
High Read another user's private data and PII |
Beginners check the URL path. Professionals check every location below. Object references can appear in at least nine distinct locations — test all of them before concluding a parameter doesn't exist.
/api/users/42/profile
/api/orders/1001/invoice
/files/report-2024.pdf
?user_id=42
?account=7a3b
?file=invoice_1001.pdf
{"order_id": 1001}
{"recipient": 42}
{"from_account": "ACC-001"}
<input type="hidden"
name="user_id"
value="42">
user_id=42
account_ref=ACC-001
role=user
X-User-ID: 42
X-Account-ID: ACC-001
X-Tenant-ID: 7
query {
user(id: 42) {
email, privateData
}
}
{"action":"get_data",
"user_id": 42,
"type": "messages"}
/exports/user_42_2024.csv
/receipts/receipt_43.pdf
No explicit ID — pattern is the ID
Beginners give up when they don't see an obvious integer. These are all testable ID formats:
| Format | Example | Testing Approach |
|---|---|---|
| Sequential integers | ?id=42 |
Increment / decrement. Try 1, 2, 41, 43, 99999 |
| UUID / GUID | a8098c1a-f86e-11da-bd1a-00112444be1e |
Appears random but not access control. Check if UUID leaks in JS source, email links, or other endpoints. Finding and using another user's UUID is still IDOR. |
| Hashed IDs | ?id=2c624232cdd221771294 |
Looks like MD5. Try md5("1"), md5("2")... Many "hashed" IDs are just md5(user_id). |
| Base64-encoded | ?token=dXNlcl9pZD00Mg== |
Decode → user_id=42. Modify → re-encode. Extremely common and extremely easy to exploit. |
| Encoded JSON objects | eyJ1c2VyX2lkIjo0MiwidHlwZSI6InVzZXIifQ== |
Base64 decode → {"user_id":42,"type":"user"}. Modify user_id, re-encode, submit. |
| Alphanumeric / prefixed | ORD-2024-0042 |
Increment the numeric suffix: ORD-2024-0041, ORD-2024-0043 |
| Timestamps | 1704067200 |
Unix timestamps as IDs — try nearby timestamps. Also combine with user ID patterns. |
Many developers believe that using UUIDs instead of sequential integers "fixes" IDOR. It does not. UUIDs are obscure, not access-controlled. If you can obtain another user's UUID — from a log, an email link, a JS response, a shared URL — and access their resource with it, that is IDOR regardless of the ID format. The fix is server-side ownership validation, not ID obfuscation.
Directly navigating to URLs that should be restricted, bypassing the intended navigation flow. The application relies on the assumption that you don't know the URL.
Forced Browsing — Path and case variations/admin → 403 Forbidden
/Admin → 200 OK (case sensitivity bypass)
/admin/ → 200 OK (trailing slash)
/admin.php → 200 OK (extension variation)
/ADMIN → 200 OK (uppercase)
/admin%20 → 200 OK (encoded space)
/./admin → 200 OK (path confusion)
/robots.txt → reveals /admin-panel-v2-secret/ (info leak)
/sitemap.xml → reveals /internal/reporting/ (info leak)
/.git/ → source code exposed (critical)
Some applications store the user's role or privilege level in a request parameter or cookie that the server trusts without re-validating against the server-side session.
Parameter-Based — Client-controlled privilege// Role stored in cookie — modify directly in browser DevTools:
Cookie: role=user → Cookie: role=admin
// Role in query parameter:
GET /dashboard?admin=false → GET /dashboard?admin=true
// Role in POST body (mass assignment):
POST /api/user/profile
{"name":"Alice", "role":"user"}
→ {"name":"Alice", "role":"admin", "is_verified":true, "balance":9999}
Some applications check the Referer header to validate that a request originated from a legitimate admin page. This is completely bypassable — the Referer header is client-controlled.
Referer Bypass// App expects admin action to come from admin dashboard:
Referer: https://app.com/admin/dashboard
// Attacker manually adds this header to any request:
GET /admin/delete-user/42 HTTP/1.1
Host: app.com
Referer: https://app.com/admin/dashboard ← spoofed
// Server grants access based on Referer alone → user deleted
Access control applied to one HTTP method but not others — a very common oversight.
Method-Based Access Control BypassGET /admin/users → 403 Forbidden
POST /admin/users → 403 Forbidden
HEAD /admin/users → 200 OK ← headers returned, confirms endpoint exists
TRACE /admin/users → 200 OK
// More dangerous pattern:
POST /admin/delete-user/42 → 403 Forbidden
DELETE /admin/delete-user/42 → 200 OK ← DELETE not in WAF ruleset
// Method override headers (tunnel DELETE inside POST):
POST /admin/delete-user/42
X-HTTP-Method-Override: DELETE
Access control checks happen at step 1 of a flow but are forgotten at step 2 or step 3.
Multi-Step Access Control Gap// Checkout flow:
Step 1: GET /checkout/1001/confirm → validates ownership ✓
Step 2: POST /checkout/1001/pay → validates ownership ✓
Step 3: GET /checkout/1001/receipt → no validation ✗
// Attacker skips to step 3 with a different order ID:
GET /checkout/1002/receipt → 200 OK — another user's receipt returned
When an API passes the entire request body to an ORM without filtering which fields are updatable, attackers can modify fields they shouldn't be able to touch.
Mass Assignment — Privilege field injection// Legitimate profile update:
PATCH /api/user/profile
{"name": "Alice", "email": "alice@email.com"}
// Attacker adds privileged fields:
PATCH /api/user/profile
{
"name": "Alice",
"email": "alice@email.com",
"role": "admin",
"is_verified": true,
"subscription_tier": "enterprise",
"account_balance": 99999
}
// If ORM applies all fields without whitelist → all fields updated in DB
// Common in: Rails (strong_params), Laravel ($fillable), Django serializers
This is the professional standard. It produces clean, unambiguous proof that no triage team can dispute — and it ensures you never accidentally access real user data.
object_id = 1002GET /api/orders/1002PATCH /api/orders/1002 with modified data. Try DELETE /api/orders/1002. Write IDORs are higher severity and less frequently tested.| Pattern | Example | Why It Gets Missed | Severity |
|---|---|---|---|
| API returns more than UI shows | GET /api/user/42 returns ssn, salary fields the frontend never renders |
Hunter only looks at what the page displays, not the raw JSON | High |
| Export / download endpoints | GET /api/export/invoice/1001.pdf |
Built separately from main API, access control middleware forgotten | High |
| Predictable filename pattern | /receipts/receipt_user42.pdf |
No numeric ID in URL path — IDOR is in the filename itself | High |
| IDOR → Account Takeover | PATCH /api/user/43 {"email":"attacker@evil.com"} → trigger password reset |
Hunter only tests read IDOR, misses write escalation chain | Critical |
| GraphQL resolver bypass | query { order(id: 1002) { creditCardLast4 } } |
Auth at route level, ownership check missing at resolver level | High |
| Multi-tenant org switching | POST /switch-org {"org_id": 999} |
Org switcher not validated — join any organization | Critical |
| Notification / email trigger | POST /notify {"user_id":43, "msg":"..."} |
Seems harmless — but harassment vector and privacy violation | Med |
Burp Intruder config for sequential ID space fuzzing// Target request in Burp Intruder:
GET /api/orders/§1001§ HTTP/1.1
Host: app.example.com
Cookie: session=ACCOUNT_A_SESSION
// Payload type: Numbers
// From: 1000 To: 2000 Step: 1
// Filter: Status 200 AND Response length > 100
// Review each 200 — check if it's your data or another user's
Autorize Burp Extension — workflow// 1. Install Autorize from Burp BApp Store
// 2. Configure Account B's session cookie in Autorize settings
// 3. Browse the application as Account A (normally)
// 4. Autorize auto-replays every request with Account B's cookie
// 5. Flags responses where:
// → Account B gets same response as Account A (no access control)
// → Status code is 200 (not 403 or 401)
// → Response length matches (same data returned)
// 6. Review flagged requests — each is a potential IDOR
// Color codes in Autorize output:
GREEN → Access enforced (different response for Account B)
RED → Potential IDOR (same response regardless of account)
ORANGE → Bypassed (Account B got access with modified request)
GET /api/orders/{id} allows any authenticated user to read other users' order data including PII (name, address, phone)order_id: 7002GET /api/orders/7002
Cookie: session=ACCOUNT_A_SESSION_TOKEN
6. Observe: Server returns 200 OK with Account B's complete order data
WHERE order_id = ? AND user_id = session.user_id. Return 404 (not 403) when ownership fails — returning 403 leaks that the object exists.Vulnerable vs Secure — Python Flask example# VULNERABLE — authentication only, no ownership check:
@app.route('/api/orders/<int:order_id>')
@login_required
def get_order(order_id):
order = Order.query.get(order_id) ← fetches any order by ID
return jsonify(order) ← no ownership validation
# SECURE — ownership enforced at the database query layer:
@app.route('/api/orders/<int:order_id>')
@login_required
def get_order(order_id):
order = Order.query.filter_by(
id=order_id,
user_id=current_user.id ← both ID and owner must match
).first_or_404() ← 404 if not found OR not owned
return jsonify(order)
The secure implementation returns 404 Not Found rather than 403 Forbidden when ownership validation fails. Returning 403 confirms to the attacker that the resource exists at that ID — leaking information about the data model. Returning 404 reveals nothing: the record either doesn't exist or you don't own it. The attacker cannot distinguish which.
Five lab modes. Each simulates a different access control failure class. Switch your active session identity and manipulate request parameters to trigger the IDOR conditions.
/api/v1/admin/.... The application checks that you're logged in, but does it check your role? Try to access admin-only functionality as a regular user.
Explain precisely why IDOR occurs even when authentication is working correctly. What is the distinction between authentication and authorization, and which one is missing in a typical IDOR vulnerability?
A developer tells you they've switched from sequential integer IDs to UUIDs and claims this "fixes" their IDOR. Explain why they are wrong. What does actually fix IDOR?
You discover a PATCH /api/user/{id} endpoint with no ownership check. The endpoint accepts an email field. Walk through the complete attack chain from this single IDOR finding to a full account takeover. What severity would you assign this and why?
You're testing a profile update endpoint. What specific field names would you add to a legitimate request body to test for mass assignment? Name at least five field names that are commonly exploitable if the ORM accepts them.