IDOR lives in the second identifier: finding BOLA with two-account Shadow Replay
Every IDOR tutorial hands you the same drill: find /api/orders/48210, change it to 48211, look for a 200. That drill trains you to attack the one identifier the application is most likely to check. The primary key in the URL path is where the framework’s @authorize annotation lives, where the ORM scopes the query, where the review focused. It’s the honeypot. You fuzz it, get 403s, call the endpoint safe, and walk right past the bug.
Because the bug is almost never in the ID you can see. It’s in the second identifier — the one the app forgot was an identifier at all.
The path ID is the one they check. The bug lives in the second identifier the request carries — the account_id in the body, the tenant in a header, the id in the GraphQL alias — the one nobody wrote an authorization rule for.
Swap the secondary identifier, not the path
Here’s the shape that pays. A request scopes an object two ways at once: a resource ID in the path, and an ownership ID elsewhere — a JSON field, a header, a hidden form value. The path ID gets validated. The ownership ID gets trusted.
POST /api/v2/reports/generate HTTP/2
authorization: Bearer <attacker token>
content-type: application/json
{ "report_id": "attacker-owned-uuid",
"account_id": 5561, ← victim's account, not yours
"format": "pdf" }
report_id belongs to you, so every resource-level check passes cleanly. But account_id decides whose data the report contains, and the handler reads it straight from the body without asking whether your session is entitled to account 5561. Leave the path alone. Change only account_id — or org_id, tenant_id, or the X-Account-Id header carrying the same meaning out of band. This is the move most hunters never make, because the tutorial trained the wrong reflex.
GraphQL: alias batching turns one request into a thousand
GraphQL is where object-level checks go to die, because the object-fetch is a shared resolver — node(id:) — reached from a dozen queries, and the authorization the REST layer bolted onto each route often never made it into that one resolver. Then GraphQL hands you a force multiplier: aliases.
query {
a: node(id: "VXNlcjox") { ... on Invoice { id total customer } }
b: node(id: "VXNlcjoy") { ... on Invoice { id total customer } }
c: node(id: "VXNlcjoz") { ... on Invoice { id total customer } }
# …200 more aliases in one HTTP request
}
Two things happen at once. First, you enumerate 200 objects in a single POST — and since most rate limiters count HTTP requests, not resolver invocations, you sail under a per-request limit that would throttle 200 REST calls. Second, all 200 route through the node() resolver, the code path that tends to omit the object-level check the REST endpoint had. Batch enumeration and the weakest check in the system, in one request. Decode the IDs first — usually base64 like Invoice:41 — increment, re-encode, alias.
Second-order IDOR: the object leaves through a side door
The nastiest variant never returns the object in the response you’re looking at. You request an export, a report, a PDF, a receipt, a webhook — and the sensitive data leaves the building asynchronously, generated by a background job from an id you supplied.
POST /api/statements/email HTTP/2
authorization: Bearer <attacker token>
{ "statement_id": 88120, "deliver_to": "[email protected]" }
The synchronous response is a bland 202 Accepted { "job": "queued" } — no object, no leak, nothing for a diff to catch. Then a worker builds statement 88120 (the victim’s) and emails the PDF to your address. The authz check was written on the synchronous read GET /api/statements/88120, which correctly returns 403. Nobody checked the async job that renders the same object. Watch what leaves the system — the email, the generated file’s URL, the webhook payload — not just the HTTP response.
The method authz forgot, and the route that skips the check
Authorization is frequently enforced on GET and quietly absent on the verbs that mutate:
GET /api/documents/7781 → 403 (checked)
PATCH /api/documents/7781 → 200 (not checked — you just edited the victim's doc)
DELETE /api/documents/7781 → 204 (not checked)
And routing quirks skip path-based checks entirely. A .json suffix, a matrix parameter, or a trailing slash can change which route pattern matches — and which middleware fires:
GET /api/documents/7781 → 403
GET /api/documents/7781.json → 200 ← different route match, check bypassed
GET /api/documents/7781;x=1 → 200 ← matrix param defeats the path regex
GET /api/documents/7781/ → 200 ← trailing slash, different handler
The check was pinned to an exact path pattern; anything that matches a different pattern reaching the same controller runs unguarded.
Mass assignment is IDOR on write
The write-side cousin: over-posting an ownership field on create or update. If the server binds the whole JSON body to a model, you assign yourself someone else’s object — or hand someone else yours.
PATCH /api/tickets/9004 HTTP/2
{ "title": "updated", "owner_id": 5561 } ← reassign ownership you don't own
Same root cause as read-IDOR: the app trusts an identifier in the body it should have derived from the session. Try user_id, owner_id, account_id, role, and is_admin on every create and update.
The detection trap: a 200 is not a finding
This is where hunters burn hours and file false positives. The status code lies in two directions.
A soft-fail returns 200 with an empty or scrubbed body — the app caught the authz failure and degraded gracefully. You log a 200, celebrate, and there’s no victim data in it. A cache or CDN returns someone else’s 200 — a shared cache key serves the victim’s cached object to your request, identical-looking but a caching bug (report it, different root cause). Either way: verify the response body actually contains the victim’s specific data — their name, their total, their record — not just a 200.
And note which boundary you crossed. Same-tenant IDOR (you see another user in your own org) is real but bounded. Cross-tenant access (you read another company’s data) is a different severity tier and a different payout. Always state the boundary in the report.
This is exactly why UUIDs don’t save the defender and shouldn’t scare you off. The check is still missing; you just harvest the identifier instead of guessing it. Pull UUIDs from confirmation emails, Referer headers, the Location header on a 201 Created, and cross-references buried in unrelated API responses. UUIDv1 even leaks a timestamp and MAC address you can narrow. A GUID is obscurity, not authorization.
Shadow Replay: diff two identities, look for absent asymmetry
The reliable way to separate a real finding from a lying status code is to run the same request as two identities and read the diff. Capture two saved identities: a low-privilege attacker and a victim who owns different objects. Replay every object-referencing request as each. The signal you want is an absence of asymmetry.
# Victim legitimately fetches their invoice:
GET /api/invoices/90431 HTTP/2
authorization: Bearer <victim token>
→ 200 OK { "id": 90431, "customer": "Northwind Ltd", "total": 8820 }
# Shadow-replayed as the attacker, same object id, nothing else changed:
GET /api/invoices/90431 HTTP/2
authorization: Bearer <attacker token>
→ 200 OK { "id": 90431, "customer": "Northwind Ltd", "total": 8820 } ← attacker must never see this
If the victim gets 200 with the object and the attacker gets 403/404, authorization works — that’s the asymmetry you want. If both return 200 with the same body, the asymmetry is gone and so is the access control. Two unrelated accounts, one object, identical bytes: that’s the finding, and the diff makes it obvious in one glance instead of ten manual re-logins.
Remediation
The fix is never “add a check at the top.” Scope every object fetch to the authenticated principal at the data layer: SELECT * FROM invoices WHERE id = ? AND account_id = <session.account_id>, so an id you don’t own returns zero rows regardless of what the body claims. Derive ownership from the session, never from a client-supplied account_id, header, or body field. Apply the identical WHERE clause on write and on the async job — the export worker and PDF renderer need it too. Allowlist bindable fields to kill mass assignment, and check per-object at the resolver, not per-route in middleware a .json suffix can dodge.
The point
IDOR isn’t hard to find; it’s hard to find where you’re not looking. The tutorial fixates you on the path ID the app almost certainly checks, while the real bug sits in the second identifier, the batched alias, the async receipt, the PATCH you never tried. All of it is business logic no scanner can confirm, because confirming it needs a known victim to diff against. Like winning the race condition, the bug was always there; the method is what surfaces it.
That’s what Crusader’s Shadow Replay is built for: define an attacker and a victim identity once, then replay any request as both and read the difference — over HTTP/2, for web, mobile, and AI targets. Point it at every object-referencing request in your history, swap the secondary identifier, and watch for the response that shouldn’t match but does. Download Crusader free.