Broken Access Control & IDOR
This is Chapter 1 of the Access & Logic notebook — Notebook 25. The Client-Side notebook (Notebook 24) lived in the browser: injecting and weaponizing script, forging requests, and defeating the Same-Origin read barrier. This notebook moves to the logic of the application itself — the rules that decide who is allowed to do what — and it opens with the single most prevalent and highest-impact class of web vulnerability in modern applications: Broken Access Control, which sits at position A01 in the OWASP Top 10 (2021), having risen there because it was found in more tested applications than any other category.
Access control is the enforcement of authorization: the rules that say user A may read their
own invoice but not user B’s, that an ordinary user may not reach the admin panel, that a
support agent may refund but not delete accounts. When those rules are missing, incomplete, or
enforced in the wrong place, an attacker reads and changes data and reaches functions they were
never meant to. The most common concrete instance is the Insecure Direct Object Reference
(IDOR): an application exposes a reference to an internal object — a numeric id, a filename,
a key — and fails to verify that the current user is authorized to act on that specific
object. Change the reference, get someone else’s object.
What makes broken access control both dangerous and beginner-approachable is that exploitation often requires no special payloads, no encoding tricks, no injection — just changing a number, a username, or an HTTP method in an otherwise ordinary authenticated request, and observing that the server complies. That simplicity is exactly why it is so common and so frequently missed by automated scanners, which struggle to know that “invoice 1001 belongs to someone else.” This chapter builds the full model and a rigorous hunting methodology, with a reproducible lab. Everything is authorized-only: accessing another user’s data is the whole point of the bug, so you prove it exclusively with your own second test account, access the minimum record needed to demonstrate the flaw, and never touch real users’ data.
Part 1: Authentication vs Authorization — The Distinction Everything Rests On
Section titled “Part 1: Authentication vs Authorization — The Distinction Everything Rests On”Two words that beginners conflate and that the entire chapter depends on separating:
- Authentication (AuthN) answers “who are you?” — proving identity via a password, a token, a passkey. Chapters on login attacks, JWTs, and sessions (the next chapters of this notebook) are about authentication.
- Authorization (AuthZ) answers “what are you allowed to do?” — deciding, for an already-identified user, whether a specific action on a specific resource is permitted.
Broken access control is an authorization failure. The attacker is usually authenticated perfectly well — they logged in with their own valid account — but the application fails to authorize the specific request. This is why “the user is logged in” is never sufficient: being authenticated says nothing about whether this user may touch this object.
flowchart LR
A["Request arrives"] --> B["AuthN: who are you?<br/>(session/token valid?)"]
B -->|invalid| X["401 Unauthorized"]
B -->|valid| C["AuthZ: may THIS user<br/>do THIS action on THIS object?"]
C -->|no| Y["403 Forbidden"]
C -->|yes| Z["Perform action"]
style C fill:#fdd
The red box — the per-request, per-object authorization decision — is where broken access control lives. Applications reliably build the AuthN box (login works) and reliably forget or under-build the AuthZ box, especially the object-level part of it (“is this specific invoice yours?”). Every technique in this chapter is a way to reach the red box and find it waved you through.
A crucial corollary: authorization must be enforced on the server, on every request, for every object. Access control implemented in the client (hiding a button, disabling a menu) is not access control at all — it’s presentation. The server must independently re-check, because the client is fully attacker-controlled.
Part 2: The Taxonomy of Broken Access Control
Section titled “Part 2: The Taxonomy of Broken Access Control”Broken access control is an umbrella. Naming its sub-types sharpens hunting, because each has a distinct test:
| Sub-type | What it is | Canonical test |
|---|---|---|
| IDOR (object-level) | Object reference not authorized for current user | Change id/key to another user’s object |
| Horizontal priv-esc | Access a peer user’s data/actions (same role) | User A reaches user B’s resource |
| Vertical priv-esc | Gain a higher role’s capabilities | Normal user reaches admin function |
| Missing function-level AC | Endpoint/function lacks any role check | Call admin API directly as normal user |
| Forced browsing | Reach unlinked/hidden pages by guessing URLs | Request /admin, /api/internal directly |
| Mass assignment | Bind attacker fields the app didn’t intend | Add "role":"admin" to a profile update |
| UI-only / client-side AC | Control enforced only in the front end | Replay the request the hidden button would send |
| Metadata/method AC gaps | Check applied to one method/content-type only | Swap GET↔POST, add .json, change verb |
Two orthogonal axes organize most of these. Direction: horizontal (same privilege level, different user — reading a peer’s invoice) vs vertical (escalating to a higher level — becoming admin). Granularity: function-level (may this role call this endpoint at all?) vs object-level (may this user act on this specific record?). IDOR is the object-level, often-horizontal case, and it is the most common of all.
mindmap
root((Broken Access Control))
Object-level
IDOR
Mass assignment
Function-level
Missing role check
Forced browsing
Direction
Horizontal (peer user)
Vertical (admin escalation)
Location of failure
UI-only enforcement
Method/content-type gap
Referer/role trust
Part 3: IDOR From Zero — The Core Mechanic
Section titled “Part 3: IDOR From Zero — The Core Mechanic”An IDOR exists when an application uses an attacker-supplied value to select an object and does not verify the requester’s authorization over that object. The classic shape:
GET /api/invoices/1001 HTTP/1.1Host: app.exampleCookie: session=<user A's valid session>If the server returns invoice 1001 merely because the session is valid — without checking that
invoice 1001 belongs to user A — then requesting 1002, 1003, … returns other users’
invoices. The “direct object reference” is the 1001; it is “insecure” because it isn’t
authorization-checked.
The reference can appear in many places, and part of hunting is knowing where to look:
- Path segments:
/api/users/42/profile,/orders/1001. - Query parameters:
?userId=42,?account=1001,?file=report_88.pdf. - Request body: JSON/form fields like
{"invoiceId": 1001}. - Headers/cookies:
X-User-Id: 42, or an ID baked into a cookie. - Indirect references gone wrong: a
filename, adocument_key, an S3 object path.
The object types worth targeting are anything user-scoped: invoices, messages, orders, profiles, support tickets, uploaded files, API keys, notification settings, cart contents, addresses.
Read vs write IDOR. A read IDOR discloses another user’s data (an information-disclosure
bug). A write IDOR lets you modify another user’s object — change their email, cancel their
order, edit their profile — which is integrity impact and usually higher severity. Always test
both: an endpoint might authorize reads but not the corresponding POST/PUT/DELETE.
Part 4: Finding the Identifiers — Discovery
Section titled “Part 4: Finding the Identifiers — Discovery”You can’t tamper with a reference you haven’t found. Identifier discovery is the first hunting phase, and thorough discovery is what separates a few IDORs from many.
Map the object references. Proxy all your traffic through Burp while exercising every feature
of the app with a normal account, then review Proxy → HTTP history and Burp’s sitemap
for every request carrying an identifier. Note the parameter names (id, uid, account,
ref, order, doc, key, file), their formats (integer? UUID? hash? base64?), and the
endpoints that consume them.
Understand the ID format, because it dictates the attack:
| ID format | Example | Attack approach |
|---|---|---|
| Sequential integer | 1001, 1002 | Increment/decrement; trivial enumeration |
| Non-sequential integer | 84213, 19022 | Enumerate a range; harvest real IDs from other responses |
| UUIDv4 (random) | f47ac10b-... | Not guessable — but leaked? Find it in another response |
| UUIDv1 (time-based) | contains MAC+timestamp | Partially predictable; may be brute-forceable |
| Hash (md5/sha of value) | c4ca4238... | If hash of a known field (id, email), forge it |
| Base64 / encoded | eyJpZCI6MTAwMX0= | Decode, tamper, re-encode |
| Username / email | ?user=alice | Substitute known/peer usernames |
Harvest real identifiers. Even “unguessable” UUIDs are often leaked elsewhere — in a listing endpoint, a search result, a notification, an autocomplete, a shared-with-me view, an error message, a referral link, or an exported file. The methodology: use one account to collect identifiers that belong to other users through any legitimate feature, then feed those into the object endpoint from your session. This defeats the “we use random IDs” defense, which is security-through-obscurity, not access control.
flowchart TD
A["Exercise app with account A (proxy on)"] --> B["Extract every object reference from history"]
B --> C{ID format?}
C -->|sequential| D["Enumerate ± around your own ID"]
C -->|random/UUID| E["Hunt for leaks: listings, search,<br/>notifications, exports, errors"]
E --> F["Collect other users' real IDs"]
D --> G["Replay object endpoint with foreign ID<br/>from account A's session"]
F --> G
G --> H{"Returns other user's object?"}
H -->|yes| I["IDOR confirmed"]
H -->|403/empty| J["Authorized correctly — try write/method/format variants"]
Part 5: The Two-Account Diffing Methodology
Section titled “Part 5: The Two-Account Diffing Methodology”The single most reliable way to find and prove access-control bugs is the two-account method. Register (or have the client provision) two accounts — call them A (attacker) and B (victim) — ideally of the same role, and a third of a higher role (admin) if you can. Then:
- Capture B’s requests. Log in as B, exercise a feature (view invoice, edit profile), and
record the exact requests and the object identifiers they use (B’s invoice is
2002). - Replay as A. Take B’s request, swap in A’s session cookie/token but keep B’s object identifier, and send it. If A receives B’s data or the action succeeds on B’s object, it’s a horizontal-access IDOR.
- Reverse it. Do the same with A’s identifiers from B’s session — asymmetric bugs exist (A can read B but not vice versa) depending on data ownership quirks.
- Escalate vertically. Take an admin-only request (captured from the admin account, or guessed) and replay it with A’s ordinary session. If it works, that’s missing function-level access control / vertical priv-esc.
The power of this method is that it produces an unambiguous PoC: “here is account A’s session token, here is account B’s invoice ID, here is the request that returned B’s data to A.” That is exactly what a triager needs, and it’s built entirely from accounts you own.
Burp automation — Autorize and Auth Analyzer. Doing the swap by hand across a large app is slow; two Burp extensions automate it:
- Autorize (BApp store): you paste account A’s low-privilege session cookie/headers into Autorize, then browse the app as account B (high-privilege). For every request B makes, Autorize automatically replays it with A’s session and compares responses, flagging each as Bypassed! (A got the same data — vuln), Enforced! (A was blocked — safe), or Is enforced??? (please configure) (ambiguous). It turns the whole app into an access- control diff in one pass.
- Auth Analyzer is a similar extension with multi-session support and fine-grained parameter matching. Either one is the workhorse for access-control testing at scale.
# Autorize result view (conceptual):GET /api/invoices/2002 Orig: 200 Modified(A): 200 -> Bypassed! <-- IDORGET /admin/users Orig: 200 Modified(A): 403 -> Enforced!POST /api/users/2002 Orig: 200 Modified(A): 200 -> Bypassed! <-- write IDORBug-bounty relevance: access-control bugs are the highest-volume category on most programs precisely because Autorize/Auth Analyzer make them findable at scale and scanners can’t. A clean two-account PoC is almost always accepted; the discipline of testing every object endpoint, both read and write, is what separates a handful of dupes from a steady stream of valid reports.
Part 6: Tampering Techniques — Beyond Incrementing a Number
Section titled “Part 6: Tampering Techniques — Beyond Incrementing a Number”When a naive ID swap returns 403, the access control might still be bypassable via how the
request is shaped. Run these variants before concluding an endpoint is safe:
HTTP method swaps. A check applied to GET may be absent on POST/PUT/DELETE (or vice
versa). Try the same object with a different verb. Also try method-override (_method=PUT,
X-HTTP-Method-Override) from the CSRF chapter.
GET /api/invoices/2002 -> 403 (authorized)POST /api/invoices/2002 -> 200 (check missing on POST!) <-- write IDOR via method gapContent-type and extension swaps. Appending .json, .xml, .csv, or changing
Content-Type sometimes routes to a different handler that lacks the check:
/api/invoices/2002 (403) vs /api/invoices/2002.json (200).
Parameter pollution and wrapping. Send the identifier twice, as an array, or nested:
?id=<mine>&id=<theirs>, id[]=<theirs>, {"id":["<mine>","<theirs>"]}. Different parsers pick
different occurrences; the auth check might read one and the data layer another.
ID in an unexpected location. Move the identifier between path, query, body, and header — an
endpoint might authorize the path id but trust a body id blindly, or honour an
X-User-Id header.
Wrapping/blank/known-object tricks. Replace your ID with 0, negative numbers, very large
numbers, null, or another account’s known ID; try referencing your own object via a different
route that skips ownership binding.
Blind writes. For write IDOR, the response may be a generic 200 OK that doesn’t echo the
victim’s data — verify the change out-of-band (log in as B, confirm B’s email actually changed).
Don’t dismiss an endpoint because the response looks empty.
| Bypass attempt | Example | Catches |
|---|---|---|
| Method swap | GET→POST/PUT/DELETE | Method-specific checks |
| Extension swap | /2002 → /2002.json | Alternate handler w/o check |
| Param pollution | id=mine&id=theirs | Parser/auth mismatch |
| ID relocation | path→body→header | Trusted secondary source |
| API version | try /v1/ when /v2/ blocks | checks added only to newer version |
| Nested object | request related sub-objects | resolver skips re-authorization |
| Method override | _method=DELETE | Verb-tunneling gaps |
| Array wrapping | id[]=theirs | Type-handling bypass |
Part 7: Mass Assignment — Writing Fields You Shouldn’t
Section titled “Part 7: Mass Assignment — Writing Fields You Shouldn’t”Mass assignment (a.k.a. autobinding / object injection) is a vertical-escalation cousin of IDOR: the application binds request parameters directly onto an internal object, and the attacker supplies fields the developer never intended to be user-settable. The classic:
PUT /api/users/me HTTP/1.1Content-Type: application/jsonCookie: session=<user A>
{"displayName":"Alice","email":"alice@x.com","role":"admin","isVerified":true,"balance":99999}If the framework’s mass-assignment (e.g. User.update(request.body)) writes every field, the
attacker just made themselves admin, verified, and rich. The developer only put
displayName/email in the form, but the server accepted more.
Discovery: look at what the app returns for an object (a GET /api/users/me often reveals
role, isAdmin, status, balance, verified, groupId fields), then try setting those
in the update request even though the UI has no field for them. The read response is your field
dictionary for the write attack.
Frameworks and the pattern: Ruby on Rails (the original “mass assignment” name, CVE-worthy
in its early days), Spring (@ModelAttribute binding), Django (ModelForm/fields='__all__'),
Node/Express (Object.assign(user, req.body)), and Laravel ($fillable vs $guarded) all
have this footgun. The fix is allow-listing bindable fields; the bug is binding everything.
Part 8: Function-Level and Forced-Browsing Failures
Section titled “Part 8: Function-Level and Forced-Browsing Failures”Not all broken access control is about objects; much is about functions — whole endpoints or admin capabilities that lack a role check.
Missing function-level access control. The app hides the admin panel link from normal users but the admin endpoints have no server-side role check. A normal user who knows (or guesses) the URL calls them directly:
POST /admin/api/users/2002/delete HTTP/1.1Cookie: session=<ordinary user>-> 200 OK (no role check — vertical priv-esc)Forced browsing is the discovery technique: guessing or enumerating unlinked URLs and
parameters. Content-discovery tools (ffuf, feroxbuster, dirsearch, Burp’s content
discovery) brute-force paths from wordlists to reveal /admin, /api/internal, /debug,
/actuator, backup files, and staging endpoints. Teach ffuf from scratch:
# ffuf — fast web fuzzer. FUZZ is the injection marker replaced by each wordlist entry.ffuf -u https://app.example/FUZZ \ -w /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt \ -H "Cookie: session=<your session>" \ -mc 200,301,302,403 \ # match these status codes (403 = exists but blocked) -fs 0 # filter out zero-length responses (noise)# -mc match-codes, -fs filter-size, -w wordlist, -H header, -u URL with FUZZ markerA 403 on /admin means it exists and is (correctly) blocked; a 200 on an admin function
from an ordinary session is the bug. Reaching an admin endpoint you enumerated and getting a
200 without an admin role is a textbook vertical-priv-esc report.
Referer/role-trust failures. Some apps gate admin functions on a weak signal — a Referer
header pointing at /admin, a role value stored in a client-editable cookie or JWT claim (see
the next chapter), or a hidden form field role=user. All are attacker-controllable and thus
not access control.
flowchart TD
A["Normal user"] --> B{"Admin function protected by...?"}
B -->|"UI hides link only"| C["Call endpoint directly → 200"]
B -->|"Referer: /admin check"| D["Forge Referer → 200"]
B -->|"role in editable cookie/JWT"| E["Flip role=admin → 200"]
B -->|"server-side role check"| F["403 — correctly enforced"]
C --> G["Vertical priv-esc"]
D --> G
E --> G
Part 9: Hands-On Lab — IDOR, Write-IDOR, Mass Assignment, and Priv-Esc
Section titled “Part 9: Hands-On Lab — IDOR, Write-IDOR, Mass Assignment, and Priv-Esc”A reproducible lab against a deliberately vulnerable API with two user accounts and an admin function. All local, all yours.
9.1 The vulnerable API
Section titled “9.1 The vulnerable API”# bac_app.py — INTENTIONALLY VULNERABLE. Lab only.from flask import Flask, request, jsonify, abortapp = Flask(__name__)
USERS = { "tokenA": {"id": 1, "name": "alice", "email": "alice@lab", "role": "user", "balance": 100}, "tokenB": {"id": 2, "name": "bob", "email": "bob@lab", "role": "user", "balance": 100},}INVOICES = {1001: {"owner": 1, "amount": 50}, 2002: {"owner": 2, "amount": 900}}
def current(req): return USERS.get(req.headers.get("Authorization", "").replace("Bearer ", ""))
@app.route("/api/invoices/<int:iid>", methods=["GET"])def get_invoice(iid): if not current(request): abort(401) inv = INVOICES.get(iid) or abort(404) return jsonify(inv) # BUG: no ownership check (read IDOR)
@app.route("/api/invoices/<int:iid>", methods=["POST"])def edit_invoice(iid): if not current(request): abort(401) INVOICES[iid]["amount"] = request.json.get("amount") # BUG: write IDOR + method gap return jsonify(INVOICES[iid])
@app.route("/api/users/me", methods=["GET", "PUT"])def me(): u = current(request) or abort(401) if request.method == "PUT": u.update(request.json) # BUG: mass assignment (role/balance settable) return jsonify(u)
@app.route("/admin/users/<int:uid>/delete", methods=["POST"])def admin_delete(uid): if not current(request): abort(401) # BUG: authenticated but no ROLE check return jsonify({"deleted": uid})
if __name__ == "__main__": app.run(port=5000)Alice’s token is tokenA (owns invoice 1001); Bob’s is tokenB (owns 2002).
9.2 Exploit 1 — read IDOR (horizontal)
Section titled “9.2 Exploit 1 — read IDOR (horizontal)”As Alice, request Bob’s invoice:
curl -s http://127.0.0.1:5000/api/invoices/2002 -H "Authorization: Bearer tokenA"# -> {"amount":900,"owner":2} Alice read Bob's invoice — IDOR confirmed9.3 Exploit 2 — write IDOR via method gap
Section titled “9.3 Exploit 2 — write IDOR via method gap”As Alice, modify Bob’s invoice:
curl -s -X POST http://127.0.0.1:5000/api/invoices/2002 \ -H "Authorization: Bearer tokenA" -H "Content-Type: application/json" \ -d '{"amount": 1}'# -> {"amount":1,"owner":2} Alice rewrote Bob's invoice — write IDOR (integrity impact)9.4 Exploit 3 — mass assignment (vertical)
Section titled “9.4 Exploit 3 — mass assignment (vertical)”As Alice, set fields the UI never offered:
curl -s -X PUT http://127.0.0.1:5000/api/users/me \ -H "Authorization: Bearer tokenA" -H "Content-Type: application/json" \ -d '{"role":"admin","balance":999999}'# -> {"balance":999999,"email":"alice@lab","id":1,"name":"alice","role":"admin"}# Alice is now admin and rich — mass assignmentNote the field dictionary came from the GET /api/users/me read response (role, balance),
exactly the Part 7 discovery technique.
9.5 Exploit 4 — missing function-level access control
Section titled “9.5 Exploit 4 — missing function-level access control”As ordinary Alice, call the admin delete:
curl -s -X POST http://127.0.0.1:5000/admin/users/2/delete -H "Authorization: Bearer tokenA"# -> {"deleted":2} Authenticated but not authorized — vertical priv-esc9.6 The fixes, demonstrated
Section titled “9.6 The fixes, demonstrated”# Read/write IDOR: verify ownership on the specific object.inv = INVOICES.get(iid) or abort(404)if inv["owner"] != current(request)["id"]: abort(403) # object-level check
# Mass assignment: allow-list bindable fields only.ALLOWED = {"name", "email"}u.update({k: v for k, v in request.json.items() if k in ALLOWED})
# Function-level: require the role, deny by default.if current(request)["role"] != "admin": abort(403)Re-running the four exploits now yields 403 on each — ownership checks stop the IDORs, the
allow-list drops role/balance, and the role check blocks the admin function.
9.7 PortSwigger access-control drills
Section titled “9.7 PortSwigger access-control drills”- "Unprotected admin functionality" → forced browsing / function-level- "Unprotected admin functionality with unpredictable URL" → URL leaked in JS- "User role controlled by request parameter" → vertical via editable role- "User role can be modified in user profile" → mass assignment- "User ID controlled by request parameter" (+ data leak / password disclosure variants) → IDOR- "URL-based access control can be circumvented" → X-Original-URL / header trust- "Method-based access control can be circumvented" → Part 6 method swap- "Multi-step process with no access control on one step" → step-skipping- "Referer-based access control" → forged RefererPart 10: IDOR in APIs, GraphQL, and Mobile Back-Ends
Section titled “Part 10: IDOR in APIs, GraphQL, and Mobile Back-Ends”Modern IDOR increasingly lives in API back-ends rather than classic server-rendered pages, and the object references hide in places worth learning to spot.
REST APIs. RESTful URLs put the object id right in the path — GET /api/v2/users/{id}/orders,
DELETE /api/v2/documents/{uuid} — which makes them the richest IDOR surface: every path id is a
candidate, and REST’s verb-per-action design means you must test each method (read, update, delete)
for its own authorization gap (the Part 6 method-swap issue). Versioned APIs (/v1/ vs /v2/) are a
special case: an older version may lack an authorization check the newer one added, so test the same
object across API versions.
GraphQL. GraphQL concentrates many objects behind one endpoint, and its flexibility creates IDOR in
distinctive ways: a query that fetches a node by id (user(id: 2002){ email }) may not check ownership;
nested resolvers can leak related objects (order(id:X){ customer { email, address } }) even when the
top-level object is authorized; and batching/aliasing lets an attacker request many ids in one query
(a: user(id:1){email} b: user(id:2){email} ...) to enumerate at scale in a single request. Also probe
GraphQL introspection to discover the schema (types, fields, ids) that reveals the object graph to
attack. Test each resolver, not just the entry query.
Mobile and thick-client back-ends. Mobile apps talk to the same APIs and often expose IDOR the web UI hides, because the mobile client sends raw ids and developers assume the API is “internal”. Proxy the mobile app (Burp with the device trusting the CA) and you frequently find object endpoints with weaker authorization than the web app — a common source of accepted reports.
GUID/UUID prediction and leakage. “We use UUIDs so IDOR is impossible” is a recurring false comfort
(Part 4). Beyond leakage, some UUIDs are predictable: UUIDv1 encodes a timestamp and MAC address, so
given a few samples you can narrow the space; sequential GUIDs (NEWSEQUENTIALID() in SQL Server) are
partially ordered; and short/custom ids may be brute-forceable. Always test whether “random” ids are
actually unpredictable and not leaked elsewhere.
flowchart TD
A["Where's the object reference?"] --> B{Surface}
B -->|"REST path id"| C["Test each verb + each API version"]
B -->|"GraphQL"| D["Node query, NESTED resolvers, batch/alias enum, introspection"]
B -->|"mobile/thick client"| E["Proxy the app → raw ids, weaker authz"]
B -->|"UUID/GUID"| F["Predictable (v1/sequential)? leaked elsewhere?"]
C --> Z["Authorization gap → IDOR"]
D --> Z
E --> Z
F --> Z
Bug-bounty relevance: the highest-volume modern IDOR reports come from API and GraphQL endpoints where the web UI’s access control wasn’t mirrored in the back-end, and from mobile back-ends. Proxy everything, enumerate every object endpoint, and test nested/batched GraphQL — that’s where the bugs are.
Part 11: Blind IDOR, Detection Nuances, and Confirming Impact
Section titled “Part 11: Blind IDOR, Detection Nuances, and Confirming Impact”Not every IDOR echoes the victim’s data back; confirming and proving them takes care.
Blind / write IDOR. A write IDOR (POST/PUT/DELETE on another user’s object) often returns a
generic 200 OK/204 with no victim data in the response — you changed something but can’t see it.
Confirm out-of-band: log in as the victim test account and verify the change actually landed (their
email changed, their order was cancelled). A “successful” empty response is not proof; the
cross-account verification is. This mirrors the blind-write discipline from the auth and business-logic
chapters.
Partial/side-channel disclosure. Sometimes you can’t read the full object but can infer its
existence or attributes: a 404 vs 403 difference reveals whether an object exists (an enumeration
oracle, like the username oracle of Chapter 2); response timing or size differences leak state;
error messages reveal owner names. Even without full read, these confirm an authorization gap worth
reporting.
Distinguishing IDOR from intended sharing. Before reporting, confirm the access is unintended: some objects are deliberately shared (public posts, team resources). The two-account method (Part 5) resolves this — if account A reaches an object that A has no legitimate relationship to (B’s private invoice), it’s IDOR; if it’s a shared team document A is a member of, it’s intended. Precision here keeps reports credible.
Automating detection at scale. Beyond Autorize/Auth Analyzer (Part 5), you can script the two-account diff: capture B’s authenticated requests, replay each with A’s session programmatically, and diff the responses for leaked B-data. The signal is “A’s request for B’s object returned B’s data” — a response that matches B’s baseline rather than an error.
flowchart LR
A["Suspected IDOR (write or read)"] --> B{Response reveals victim data?}
B -->|"yes"| C["Direct confirmation"]
B -->|"generic 200/204"| D["Verify out-of-band as victim account"]
B -->|"404 vs 403 / timing / size"| E["Side-channel: existence/attribute oracle"]
C --> F["Confirm it's UNINTENDED (2-account, no legit relationship)"]
D --> F
E --> F
F --> G["Report with clean 2-account PoC"]
Reporting discipline. Prove with two accounts you own — “here is A’s token, B’s object id, the request, and B’s data returned to A (or the change verified in B’s account)”. Access the minimum record needed; never enumerate real users’ data to show scale (describe the enumerability instead). For a sequential-id read IDOR, demonstrating two or three of your own objects plus the pattern is enough to establish mass-disclosure impact without touching anyone else.
Part 12: Real-World Impact, CVEs & Escalation
Section titled “Part 12: Real-World Impact, CVEs & Escalation”Broken access control is A01 because its impact is broad and its instances are everywhere:
- Mass PII disclosure via sequential IDOR. The recurring headline breach: an API endpoint
like
/api/user/{id}with sequential IDs and no ownership check lets an attacker enumerate and scrape every user’s records. Numerous large disclosures — mobile-app backends, government portals, telecoms — trace to exactly this. Severity is amplified by scale: one bug, millions of records. - Account takeover via write IDOR / mass assignment. Changing another user’s email or
flipping your own
role/isAdminfield converts an access-control bug into full takeover or privilege escalation. - Financial/loyalty abuse. IDOR on orders, refunds, gift-card balances, or coupon objects.
- Insecure Direct Object Reference in files/S3. Predictable
document_id/object keys exposing other tenants’ uploads — a frequent multi-tenant SaaS failure. - Historical framework CVEs around mass assignment (early Rails) and default-permissive autobinding shaped how modern frameworks now require explicit allow-listing.
The escalation logic: read IDOR → data disclosure; write IDOR → integrity/ATO; function-level gap → privilege escalation; mass assignment → self-promotion to admin. Scale (sequential, enumerable IDs) turns any of these from “one record” into “the whole database.”
flowchart LR
A["Find object reference"] --> B{read or write?}
B -->|read| C["Other user's data"]
C --> D{"IDs enumerable?"}
D -->|yes| E["Mass scrape → breach"]
D -->|no| F["Targeted disclosure"]
B -->|write| G["Modify other user / self role"]
G --> H["ATO / privilege escalation"]
A useful mental model for prioritising is the object-graph sweep: list every object type the app
exposes (users, orders, invoices, messages, files, API keys, settings, team resources), and for each ask
“can I reference another principal’s instance, for read and for write, via any surface (web, REST,
GraphQL, mobile)?” The bugs cluster where a new surface (a mobile API, a /v2/ endpoint, a GraphQL
resolver) was added without mirroring the web app’s checks, and where nested objects are returned
without re-authorizing them. Systematically walking the object graph — rather than testing whichever
endpoint you happen to notice — is what turns IDOR hunting into reliable coverage.
CTF relevance: web CTFs frequently hide a flag in “another user’s” or “the admin’s” object —
the intended solve is an IDOR (swap the id) or forced browsing to an admin route. picoCTF and
many HTB/THM web challenges use this pattern.
Part 13: Detection & Defense Angle
Section titled “Part 13: Detection & Defense Angle”Access control must be designed in, not bolted on. The controls, in priority order:
1. Deny by default. Every resource is inaccessible unless a rule explicitly grants access. New endpoints inherit “deny” until authorization is added — the opposite of the common “everything’s open until someone remembers to lock it.”
2. Enforce on the server, on every request, for every object. Never rely on the UI hiding things. The authorization decision — may this authenticated principal perform this action on this specific object? — runs server-side for each request.
3. Object-level checks (kills IDOR). For every object access, verify ownership/permission on
that object: if resource.owner_id != current_user.id and not current_user.can(resource): deny. This single pattern, applied everywhere, eliminates the IDOR class. Don’t rely on
unpredictable IDs — treat random IDs as a defence-in-depth bonus, never the control.
4. Centralize authorization. Route decisions through one policy layer (middleware, a policy
engine, framework guards) rather than scattering ad-hoc if checks that drift out of sync.
Centralization is why an endpoint can’t be “forgotten.” Patterns: RBAC (roles), ABAC
(attributes), ReBAC/relationship-based (e.g. Google Zanzibar-style) for object-level.
5. Allow-list bindable fields (kills mass assignment). Bind only explicitly permitted
attributes; never update(request.body) wholesale. Keep security-sensitive fields (role,
balance, verified, owner) server-controlled.
6. Consistent enforcement across methods, content-types, and steps. Apply the same check to
GET/POST/PUT/DELETE, to .json/.xml variants, and to every step of multi-step flows — the
Part 6 bypasses exist because checks were inconsistent.
7. Don’t trust client-controlled inputs for authorization — not Referer, not a hidden
role field, not an editable cookie/JWT claim, not X-Original-URL/X-Rewrite-URL headers
(which some front-end access-control setups honour and attackers abuse).
Reference: the object-level authorization check that kills IDOR. Every fix reduces to verifying the current principal’s relationship to the specific object, centrally, on every access:
# Per-object ownership check — the pattern that eliminates IDOR when applied everywhere.def get_invoice(invoice_id, current_user): inv = db.invoices.get(invoice_id) or abort(404) if inv.owner_id != current_user.id and not current_user.can_access(inv): # relationship check abort(403) # deny by default return inv# Centralized policy (preferred) — decisions in one place, not scattered ifs. e.g. a policy/guard layer:@authorize("read", resource="invoice") # framework resolves object + checks the principal's relationdef view_invoice(invoice_id): ...# Mass-assignment guard — bind only allowed fields, never the whole request body.ALLOWED = {"name", "email"}user.update({k: v for k, v in request.json.items() if k in ALLOWED}) # role/balance stay server-ownedThe decisive property in all three is that authorization is decided from the server’s knowledge of who
owns what — never from an id the client supplied as “proof”. Object-level checks kill IDOR; a centralized
policy layer stops endpoints being “forgotten”; and the bindable-field allow-list stops mass assignment.
Frameworks help: use a policy/ability library (Pundit/CanCanCan in Rails, Django Guardian/DRF permissions,
Spring @PreAuthorize / method security, Laravel Policies) rather than hand-rolled if checks that drift
out of sync across dozens of endpoints.
Detection signals:
| Signal | Where | Indicates |
|---|---|---|
| One session requesting many sequential object IDs | Access logs / WAF | IDOR enumeration / scraping |
403/404 clusters then a 200 on the same pattern | App logs | Access-control probing that found a hole |
| Ordinary-role principals hitting admin endpoints | App/audit logs | Function-level bypass attempts |
| Object accessed by a principal who never “owns” it | App-level audit | Successful IDOR |
Profile updates carrying unexpected fields (role, balance) | Input validation logs | Mass-assignment attempt |
| Content-discovery UA / rapid path fuzzing | WAF | Forced browsing (ffuf/feroxbuster) |
| GraphQL queries with many aliased node lookups | App/GraphQL logs | Batch-alias IDOR enumeration |
| Introspection queries from non-dev clients | GraphQL logs | Schema mapping for object attack |
Blue-team usage: log the authorization decision (principal, object, action, allow/deny), not just the HTTP request — this is what lets you detect “user 1 read object owned by user 2.” Rate-limit and alert on per-session object-ID diversity to catch enumeration. IR use case: on a suspected IDOR breach, reconstruct which object IDs a single session touched and which it did not own; sequential, cross-owner access from one principal is the fingerprint.
Part 14: Common Pitfalls & Gotchas
Section titled “Part 14: Common Pitfalls & Gotchas”- Testing only reads. Write IDOR (POST/PUT/DELETE) is higher impact and often unprotected even when reads are — always test both directions.
- Trusting unpredictable IDs. UUIDs are not access control; they leak. Enforce object-level checks regardless of ID format.
- Stopping at the first
403. Run the Part 6 variants (method, extension, param pollution, ID relocation) before concluding an endpoint is safe. - Blind-write false negatives. A generic
200may still have changed the victim’s object — verify out-of-band with the victim account. - UI-only “fixes.” Hiding a button or disabling a field changes nothing server-side; replay the request directly.
- Client-side role trust. A
roleclaim in an editable cookie/JWT or a hidden form field is attacker-controlled (JWT integrity is the next chapter). - Scanner reliance. Automated scanners miss IDOR because they don’t know object ownership; human two-account testing (Autorize/Auth Analyzer) is required.
- Forgetting multi-step flows. A check on step 1 but not step 3 lets an attacker jump straight to the unprotected step.
Part 15: Final Revision / Summary
Section titled “Part 15: Final Revision / Summary”- Broken access control is an authorization failure (Part 1): the user is authenticated fine; the app fails to check whether this user may do this action on this object. It’s OWASP A01 — the most prevalent class.
- The taxonomy (Part 2): IDOR (object-level), horizontal vs vertical priv-esc, missing function-level control, forced browsing, mass assignment, UI-only/method-gap enforcement.
- IDOR (Parts 3–4) exposes an object reference without checking ownership; find identifiers everywhere (path/query/body/header), understand the ID format, and harvest real IDs even when they’re random — obscurity isn’t access control.
- The two-account diffing method (Part 5), automated with Burp Autorize/Auth Analyzer, reliably finds and proves horizontal and vertical bugs.
- When a naive swap fails, apply tampering variants (Part 6): method/extension swaps, parameter pollution, ID relocation, method override.
- Mass assignment (Part 7) writes unintended fields (
role,balance); function-level and forced-browsing failures (Part 8) reach admin endpoints with no role check. - Modern surfaces (Part 10): most IDOR now lives in REST path ids (test each verb + API version), GraphQL (nested resolvers, batch/alias enumeration, introspection), and mobile back-ends whose raw ids the web UI hid; “we use UUIDs” is not a defense (they leak or, for v1/sequential, predict).
- Confirming impact (Part 11): write/blind IDOR needs out-of-band verification in the victim account;
404vs403, timing, and size differences are existence/attribute oracles; always confirm the access is unintended with the two-account method before reporting. - Defend (Part 13) with deny-by-default, server-side per-object checks (kills IDOR), centralized authorization, bindable-field allow-lists (kills mass assignment), consistent enforcement across methods/steps, and never trusting client-supplied authorization inputs.
Part 16: Cheat Sheet / Quick Reference
Section titled “Part 16: Cheat Sheet / Quick Reference”IDOR test
# Swap the object id, keep YOUR session — does it return someone else's object?curl -s https://app/api/invoices/<THEIR_ID> -H "Authorization: Bearer <YOUR_TOKEN>"Tampering variants
| Try | Example |
|---|---|
| Method swap | GET→POST/PUT/DELETE |
| Extension | /2002 → /2002.json |
| Param pollution | ?id=mine&id=theirs, id[]=theirs |
| ID relocation | path ↔ body ↔ X-User-Id header |
| Method override | _method=DELETE, X-HTTP-Method-Override |
| Header trust | X-Original-URL: /admin, forged Referer |
Mass assignment
# Field dictionary comes from the object's GET response; then set them in the write:curl -X PUT https://app/api/users/me -H "Authorization: Bearer <YOU>" \ -H "Content-Type: application/json" -d '{"role":"admin","isVerified":true}'Forced browsing
ffuf -u https://app/FUZZ -w raft-medium-directories.txt -H "Cookie: session=<you>" -mc 200,301,403Defense map
| Control | Kills |
|---|---|
| Deny by default | forced browsing / new-endpoint gaps |
| Server-side per-object ownership check | IDOR (read + write) |
| Centralized authorization policy | inconsistent/forgotten checks |
| Bindable-field allow-list | mass assignment |
| Same check across methods/content-types/steps | Part 6 bypasses |
| No client-controlled authz inputs | Referer/role/X-Original-URL trust |
| Re-authorize nested objects (GraphQL) | nested-resolver leakage |
| Mirror checks across web/REST/GraphQL/mobile | new-surface authz gaps |
GraphQL / API IDOR quick tests
| Surface | Test |
|---|---|
| REST path id | swap id; repeat for GET/PUT/PATCH/DELETE; try /v1/ vs /v2/ |
| GraphQL node | user(id: <theirs>){...}; check ownership |
| GraphQL nested | order(id:X){ customer{ email } } — is the nested object re-authorized? |
| GraphQL batch | aliases a:user(id:1) b:user(id:2)... — enumerate in one request |
| Mobile back-end | proxy the app; test raw ids the web UI hides |
| UUID | is it leaked elsewhere? UUIDv1/sequential → predictable? |
| Blind write | generic 200/204 → verify change in the victim account |
| Existence oracle | 404 vs 403 difference reveals object existence |
Burp workflow: proxy as user B → run Autorize with user A’s session → review Bypassed! flags → confirm read and write, then build the two-account PoC.
Object-graph sweep: enumerate every object type (users, orders, invoices, messages, files, keys, settings) and, for each, test read and write cross-account across every surface (web, REST, GraphQL, mobile). The bugs cluster where a new surface skipped the web app’s checks or where nested objects aren’t re-authorized.
Part 17: Practice Labs & Resources
Section titled “Part 17: Practice Labs & Resources”- PortSwigger Web Security Academy — Access control (all labs): “Unprotected admin functionality” (+ unpredictable URL), “User role controlled by request parameter”, “User role can be modified in user profile” (mass assignment), the “User ID controlled by request parameter” family (IDOR, with data-leak and password-disclosure variants), “URL-based / Method-based access control can be circumvented”, “Multi-step process with no access control on one step”, and “Referer-based access control”. These map one-to-one onto Parts 3–8. Free, with victim accounts provided.
- PortSwigger IDOR topic page and the OWASP Testing Guide (WSTG-ATHZ) — authoritative methodology.
- Burp extensions Autorize and Auth Analyzer (BApp store) — the Part 5 automation; practice configuring them against Juice Shop.
- OWASP Juice Shop: many access-control challenges (view another basket, access admin section, forged coupons) — ideal for end-to-end two-account practice.
- HackTheBox / TryHackMe — “IDOR”, “Broken Access Control”, and web-app rooms: guided targets.
- GraphQL testing tools (InQL, GraphQL Voyager, graphql-cop): map the schema via introspection and test nested/batched IDOR (Part 10).
- Proxying mobile apps (Burp + device CA): practice finding back-end IDOR the web UI hides.
- Disclosed HackerOne reports (filter
idor/broken access control): the highest-volume accepted category — study how testers prove ownership violations with two accounts.
Practice questions
- An endpoint
/api/orders/{uuid}uses random UUIDv4 order IDs and returns any order to any authenticated user. The vendor argues it’s safe because “UUIDs are unguessable.” Explain why this is still an IDOR and describe three places you’d realistically find other users’ order UUIDs to prove it. GET /api/profile/2002returns403for your session, butPOST /api/profile/2002with a body succeeds. Name the sub-class of bug, why it happens, and how you’d verify the write actually affected the victim.- You can read your own user object at
GET /api/users/meand it includes arolefield. The profile form only has name and email. Describe the exact request you’d send to test for mass assignment and how you’d confirm success. - Contrast horizontal and vertical privilege escalation with a concrete example of each against an e-commerce app, and state which OWASP Top 10 category both fall under.
- Given only application access logs, describe the query and the specific pattern you’d look for to detect an attacker enumerating a sequential-ID IDOR, and one server-side change that would both fix the bug and improve detectability.
- A GraphQL API authorizes the top-level
order(id:)query but returns a nestedcustomerobject without a separate check. Explain how this leaks another user’s data, and how batching/aliasing lets an attacker enumerate at scale in one request. - A write IDOR (
PUT /api/users/2002) returns a generic200 OKwith no body. Explain why this is not yet proof of the vulnerability, and the exact out-of-band step that confirms it, using two test accounts.
Part 18: Closing — Why Access Control Is Designed, Not Patched
Section titled “Part 18: Closing — Why Access Control Is Designed, Not Patched”The through-line of this chapter is that broken access control is not a coding slip you fix with a
sanitiser or an escape function — it is a design property. An application either has a coherent,
centralized model of “which principal may perform which action on which object”, enforced server-side on
every request, or it has a scattering of ad-hoc checks that inevitably leave gaps as new endpoints, API
versions, and surfaces are added. That is why access control tops the OWASP Top 10: it fails not because
developers don’t know ../ is dangerous, but because authorization is cross-cutting and easy to forget
in one of the hundreds of places it must hold.
For the tester, this means coverage beats cleverness: the object-graph sweep, the two-account diff across every surface, and the discipline of testing both read and write for every object are what find these bugs — not exotic payloads. For the defender, it means the fix is architectural: deny by default, a single policy layer every request flows through, per-object relationship checks, bindable-field allow-lists, and consistent enforcement across methods, versions, and clients. Get the design right and the whole class largely disappears; leave it to per-endpoint vigilance and IDOR will keep reappearing with every new feature. The next chapters — authentication, tokens, sessions, and business logic — each attack a different part of this same “who are you and what may you do” question, and each rewards the same systematic, coverage-first mindset.
Discussion
Questions, corrections, or something to add? Comment with your GitHub account.