← Return to Ledger
MODULE_09 // BROKEN OBJECT LEVEL AUTHORIZATION

IDOR & Access Control

Difficulty Intermediate
Read time ~40 min
Labs 5 sandbox modes + 7 drills
0/7 complete

OPERATIONAL OBJECTIVE

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.

What Is Access Control? #

Access control enforces which users can perform which actions on which resources. Every request to a protected resource must answer three separate questions:

QuestionMechanismWhere It Fails
Are you who you say you are?Authentication — login systems, session tokens, MFACredential theft, session hijacking
Are you allowed to do this action?Authorization — role checks, privilege levelsVertical privilege escalation, forced browsing
Do you own this specific object?Resource ownership — per-object authorization checksIDOR — 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.

OWASP TOP 10 — #1

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 — The Core Concept #

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)

Full Taxonomy #

Horizontal IDOR High
  • Access to another user's data at the same privilege level
  • Regular user → another regular user's data
  • Read: other user's messages, orders, medical records
  • Write: modify other user's profile, settings, email
  • Delete: remove other user's content or resources
Vertical IDOR (Priv-Esc) Critical
  • Access to higher-privilege functions from a lower-privilege account
  • Regular user → admin endpoints or admin data
  • /api/admin/users — full user database
  • /api/admin/delete-user/42 — delete any account
  • Free tier user → premium feature API calls

IDOR on Write Operations — Highest Severity

Most hunters test GET requests only. Write-operation IDOR is far more impactful and far less tested:

MethodExampleImpact
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

Where IDs Live — Full Surface Map #

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.

URL Path Segments
/api/users/42/profile
/api/orders/1001/invoice
/files/report-2024.pdf
Query Parameters
?user_id=42
?account=7a3b
?file=invoice_1001.pdf
JSON Request Body
{"order_id": 1001}
{"recipient": 42}
{"from_account": "ACC-001"}
Hidden Form Fields
<input type="hidden"
  name="user_id"
  value="42">
Cookies
user_id=42
account_ref=ACC-001
role=user
Custom Headers
X-User-ID: 42
X-Account-ID: ACC-001
X-Tenant-ID: 7
GraphQL Variables
query {
  user(id: 42) {
    email, privateData
  }
}
WebSocket Messages
{"action":"get_data",
 "user_id": 42,
 "type": "messages"}
Predictable Filenames
/exports/user_42_2024.csv
/receipts/receipt_43.pdf
No explicit ID — pattern is the ID

ID Formats — Don't Stop at Sequential Numbers #

Beginners give up when they don't see an obvious integer. These are all testable ID formats:

FormatExampleTesting 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.

UUID IDOR — A COMMON MISCONCEPTION

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.

Access Control Bypasses — Beyond IDOR #

Forced Browsing

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)

Parameter-Based Access Control

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}

Referer-Based Access Control

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

HTTP Method Switching

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

Multi-Step Flow Bypass

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

Mass Assignment

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

The Two-Account Testing Methodology #

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.

1
Setup: Register Account A (attacker) and Account B (victim) on the target. Use separate browsers or one browser + one incognito window. Keep both sessions active simultaneously.
2
Map Account B's resources: As Account B, create a resource (message, order, profile, file upload). Capture its identifier from the URL or response JSON. Note it: object_id = 1002
3
Switch to Account A: In Burp Repeater, use Account A's session cookie. Build a request targeting Account B's object ID. Send: GET /api/orders/1002
4
Test write operations: Don't stop at GET. Try PATCH /api/orders/1002 with modified data. Try DELETE /api/orders/1002. Write IDORs are higher severity and less frequently tested.
5
Document cleanly: Screenshot the Burp request (Account A's session cookie) and the response (Account B's data). You own both accounts — this is clean proof with no risk to real users.

Real-World Bug Bounty Patterns #

PatternExampleWhy It Gets MissedSeverity
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

Tooling — Burp Suite + Autorize #

Burp Intruder — ID Fuzzing

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 — Automated IDOR Detection

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)

What a Paid IDOR Report Looks Like #

Title
IDOR on GET /api/orders/{id} allows any authenticated user to read other users' order data including PII (name, address, phone)
Severity
High — Exposes personal data of all users. Affects entire user base. Data includes name, shipping address, phone number, and purchase history.
Steps to Reproduce
1. Register Account A at [URL] with email: attacker@test.com
2. Register Account B at [URL] with email: victim@test.com
3. Log in as Account B. Create an order. Note the order ID in the response: order_id: 7002
4. Log out of Account B. Log in as Account A.
5. Send the following request using Account A's session:
GET /api/orders/7002
Cookie: session=ACCOUNT_A_SESSION_TOKEN
6. Observe: Server returns 200 OK with Account B's complete order data
Evidence
[Burp request screenshot — showing Account A's cookie]
[Response screenshot — showing Account B's name, address, phone]
Impact
An attacker could enumerate all order IDs from 1 to N and download every user's PII. No special tools required — only a browser and an active account.
Suggested Fix
Add server-side ownership check: WHERE order_id = ? AND user_id = session.user_id. Return 404 (not 403) when ownership fails — returning 403 leaks that the object exists.

Prevention — The Correct Fix #

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)

WHY 404 NOT 403

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.

Interactive Lab — Access Control Sandbox #

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.

⬡ Access Control Lab Environment API: /api/v1/ / AUTH: SESSION-BASED
① Read IDOR
② Write IDOR
③ Vertical Priv-Esc
④ Mass Assignment
⑤ GraphQL IDOR
Active Session:
👤 Alice (user_id: 42, role: user)
👤 Bob (user_id: 43, role: user)
Scenario — Horizontal Read IDOR
Alice owns Order #7001. Bob owns Order #7002. As Alice, access an order — then try accessing Bob's. The server validates your login but skips ownership checks.
GET /api/v1/orders/
Hints: 7001 (Alice's) 7002 (Bob's) 7003 (unknown) 1 (guess)
API Response
Awaiting request...
Scenario — Write IDOR (PATCH + DELETE)
As Alice, try modifying or deleting Bob's order (#7002). Most hunters only test GET — write IDORs are higher severity and less tested. Try both PATCH and DELETE.
PATCH /api/v1/orders/
Try: 7001 (your own) PATCH 7002 DELETE 7002
API Response
Awaiting request...
Scenario — Vertical Privilege Escalation
You are a regular user (Alice). Admin endpoints exist at /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.
GET /api/v1/
Try paths: user/42/profile admin/users admin/delete-user/43 admin/export-db admin/config
API Response
Awaiting request...
Scenario — Mass Assignment
A profile update endpoint accepts a JSON body. The backend passes the entire body to the ORM without whitelisting. Add hidden privileged fields to your update — try to escalate your own account's role, verification status, or subscription tier.
PATCH /api/v1/user/42/profile
Request body — edit values, add extra fields:
API Response — Database State After Update
Awaiting request...
Scenario — GraphQL IDOR
A GraphQL API. Authentication is enforced at the route level — but ownership checks are missing inside the resolvers. As Alice (user 42), try querying Bob's data (user 43) or using introspection to discover hidden query types.
POST /api/graphql
Try: Own profile (42) Other user (43) Introspection Delete mutation
GraphQL Response
Awaiting query...

Chapter 9 — Operational Drills #

PortSwigger Web Security Academy
Two-Account Live Testing
Knowledge Verification
Check 01 — Root Cause

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?

Check 02 — UUID False Security

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?

Check 03 — Write IDOR Chain

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?

Check 04 — Mass Assignment Fields

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.