DAST scanner · special

IDOR and Cross-User BOLA

An insecure direct object reference is an id in a URL that the server looks up without asking who is asking. isitsecure probes ids four ways with no credentials, which can only produce leads, and then logs in as two users so that User B can try to read and write User A's real objects. That second pass is the only one that confirms anything.

DASTMediumHighCriticalIDORBOLA

Without credentials: four probes, one ceiling

With no login the scanner can still learn whether an id is real and whether the endpoint answers strangers. It cannot learn whether the object was supposed to be private. The scene shows the four probes and the rule that caps what they may claim.

Scanner no credentials no Authorization no cookie Target API /api/tasks/{id} /api/tasks?user_id answers anyone 1 · Unauthenticated access GET /api/tasks/42 no auth header {"user":{}} → no value, ignored · {"id":42,"title":"Q3"} → data, public 2 · Path-parameter swap GET /api/tasks/42 → GET /api/tasks/43 compare the two bodies bodies differ → real reference, a lead · same body → id ignored 3 · Query-parameter swap GET /api/tasks?user_id=A → ?user_id=B id-bearing params only same comparison: a different body per value makes the param a real reference 4 · Sequential enumeration 42 → 43 · 41 · 142 id+1, id−1, id+100 numeric ids only: a neighbour that answers means every record is guessable Risk rule without credentials /api/Products/{id} public catalogue · new object per id /api/Recycles/{id} private record · new object per id identical to an anonymous probe different data per id → at most POSSIBLE · same body for every id → SAFE

1Unauthenticated access. The request carries no auth at all. "Returns data" is a structural check: the JSON must contain a non-empty scalar somewhere. An empty envelope like {"user":{}} is the server saying nobody, so it is not flagged, and the check does not depend on recognising data or result keys.

2Path-parameter swap. Change /api/tasks/42 to /api/tasks/43 and compare the response with the original id's. Different data means the id is a real object reference; identical means it is ignored. Without a login this is only a lead.

3Query-parameter swap. The same comparison on id-bearing query parameters: ?user_id=A becomes ?user_id=B.

4Sequential enumeration. For numeric ids, try id+1, id−1 and id+100. If neighbours answer, every record in the table can be walked.

5The ceiling. A public catalogue and a private record both return a different object per id with no auth; only ownership intent separates them, and that is invisible without credentials. So an unauthenticated swap that returns different data per id is at most POSSIBLE (confidence 0.60, a lead to verify), and an id that returns the same response however you change it is SAFE. CONFIRMED is reserved for the cross-user pass.

The cross-user pass: User B asks for User A's objects

With two accounts the question becomes answerable: does the server check ownership? The scanner does not guess ids. It logs in as A, reads A's collections to learn which ids A really owns, and then requests each of them as B.

User A · owner Bearer eyJ…A browser or REST login User B · attacker Bearer eyJ…B a different real account Target API GET /api/tasks GET /api/tasks/{id} A owns 42 and 57 no ownership check Harvest A's real ids from the parent collection A GET /api/tasks → [{"id":42,"title":"Q3 budget"}, {"id":57,"title":"Hiring"}] candidates: 42 · 57 7f3a-…-c0dea@example.comdropped harvested ids are all numeric → keep numeric only · identity values only for a user-named slot Request each harvested id as User B A GET /api/tasks/42 → 200 {"id":42,"title":"Q3 budget","owner":"A"} B GET /api/tasks/42 → 200 {"id":42,"title":"Q3 budget","owner":"A"} same bytes as the owner saw · anonymous probe got 401 (next scene) CONFIRMED · cross-user read · confidence 0.95 · one finding per endpoint GET reads · PUT/PATCH writes · POST creates and DELETE destroys, so neither is tried

1Two logins. The browser login helper finds the visible password field and fills the identity field in the same form, whatever it is named. For a frontend-less API, RestLoginAuthProvider POSTs the credentials and lifts the bearer token out of the JSON. Either way: a session for A and a session for B.

2Harvest. For an endpoint like /api/tasks/{id}, read the parent collection /api/tasks as A. The ids in that response are A's real objects, so no guessing. Both numeric and UUID shapes are handled.

3Candidates. If every harvested id is numeric, the candidate set is filtered to numeric ids so UUIDs are not wasted. A's email or user id is only injected when the path parameter is named like an identity (user, email, account), never into an opaque object-id slot.

4Owner baseline. A must be able to read its own object with a 2xx. If not, there is nothing to compare against and the id is skipped.

5Attacker probe. The same URL with B's token. B gets 200 and the body matches what A saw (identical, or equal once per-request fields such as timestamps and request ids are ignored). Object 42 belongs to A, so the server never checked ownership.

6Confirmed. This is the only path to CONFIRMED: it proves B can read A's object. The two guards in the next scene run inside this step and are what keep it honest.

The two guards that kill false positives

A 200 for User B is not enough. The endpoint may be public to everyone, or it may have ignored the id and returned B's own data. Each probe passes through four stages and either guard can drop it.

1 · Owner baseline A GET /api/tasks/42 200 {"id":42,"title":"Q3"} A must reach its own object with a 2xx and data; otherwise skip this id 2 · Anonymous guard — GET /api/tasks/42 no token at all 401 / 403 → private, go on 200 → public, suppress a catalogue is not an IDOR 3 · Attacker probe B GET /api/tasks/42 B's own token 401 / 403 / 404 → drop 200 → candidate a status alone proves nothing yet 4 · Content match B.body == A.body ? equal → CONFIRMED differs → drop an empty or generic body, or B's own data, never counts The owner read is the reference. Everything later is compared against this exact body. Guard 1. If a request with no auth gets through, the endpoint is simply public and the cross-user finding is suppressed.That is how /api/Products/{id} on a shop stays out of the report. B's token reaches A's URL. A 2xx makes it a candidate, a 4xx ends the probe. Writes only need to get past access control here. Guard 2. B's response must equal A's body. If the bodies differ, the id was ignored or coerced to B's own record: caller-scoped, not IDOR.Only the equal case becomes a finding: CONFIRMED, confidence 0.95.

1Owner baseline. A reads the object with its own token. The response must be a 2xx carrying data; this body is what B's will be compared against.

2Anonymous-access guard. The same URL is requested with no auth at all, before B tries. If that returns anything but 401 or 403 the resource is public, so a cross-user hit would be meaningless and the finding is suppressed.

3Attacker probe. B requests the URL. A 4xx ends it. A 2xx is only a candidate.

4Content-match guard. B's body must equal A's body. The comparison is exact equality of the two body texts (not a search for A's values inside B's body). Any difference, including a changed timestamp or a request id, drops the candidate as caller-scoped data, so a real hit on a response that carries volatile fields can be missed. For writes only the 401/403 check applies.

Mutation read-back: a 2xx is not a write

For PUT and PATCH a success status only proves the request was accepted. Many servers answer 200 and change nothing. The probe writes a harmless canary, reads it back, and restores the original before deciding anything.

User B authenticated session runs only under --probe-writes it writes to the target Target PUT /api/tasks/42 A's task notes: "call Sam" notes: canary notes: "call Sam" 0 · Gate: is this the SPA shell? PUT /api/tasks/42 → 200 text/html <!doctype html> rejected, not a write PUT /api/tasks/42 → 200 application/json continue 1 · Write a canary B PUT {"notes":"isitsecure_rb_3f9a"} one existing, non-sensitive field never money, auth or PII → 200 accepted, not yet proven 2 · Read it back GET /api/tasks/42 notes == "isitsecure_rb_3f9a" yes the write response echoing it is proof enough on its own 3 · Restore PUT {"notes":"call Sam"} the original value is always put back persisted → CRITICAL B changed A's record and it stuck cross-user write · confidence 0.98 unconfirmable → HIGH lead read is gated and the write did not echo reported, but downgraded GET /api/tasks/42 → 401 and PUT answers with nothing to read: cannot confirm, so HIGH. GET → 401 but the PUT echoes the object with the canary: still CONFIRMED, from the write response. A code-split front end serves index.html with a 200 for unknown routes. That answer is never treated as a write.

1Gate first. A single-page app answers unknown routes with its shell, 200 text/html. That response is rejected before any probe runs, so an app shell can never count as an accepted write. Mutation probes run authenticated when a session exists and only under --probe-writes.

2Canary. B sends a PUT that changes exactly one existing, non-sensitive field of A's object to a marker value. Money, auth and PII fields are never touched. The 200 that comes back only says the request was accepted.

3Read back. The object is fetched again. If the field now holds the canary, the write really landed. When the write's own response already echoes the canary, that is proof enough and no re-read is needed.

4Restore and decide. The original value is written back. Confirmed persistence is CRITICAL: B modified A's data and it stuck. Each endpoint yields one finding; self-swaps are skipped and probing stops at the first accepted swap.

5When it cannot be confirmed. If the read is auth-gated and the write echoes nothing, persistence is unknown and the finding is downgraded to a HIGH lead. A write-open, read-gated endpoint whose PUT echoes the object is still confirmed from the write response alone. That is what reaches an auth-gated BOLA delete like Juice Shop's "delete another user's feedback".

Why it matters

The most common API bug

IDOR is the #1 most common vulnerability in modern web APIs. Unlike SQL injection it needs no payload; it is just changing a number in a URL, which makes it trivial to exploit and devastating in impact.

  • Read other users' private data: messages, documents, medical and financial records.
  • Modify other users' data: change settings, delete files, alter records.
  • Escalate privileges: reach admin-only resources by guessing admin ids.
  • Mass data theft: enumerate every record by iterating ids.
Real-world breaches

First American Financial (2019). 885 million mortgage documents (Social Security numbers, bank statements, tax records) were exposed because document URLs used sequential record numbers with no authorization check. Changing the id in the URL returned any customer's documents.

Parler (2021). After the platform was deplatformed, researchers scraped all posts, including deleted content and GPS metadata, because the API used sequential post ids with no access control.

Vulnerable vs fixed code

The vulnerable handlers look an object up by whatever id the request supplies. The fix adds the authenticated user to the query, or moves the rule into the database as a row-level security policy.

// BAD: No ownership check — any authenticated user can access any task
export async function GET(request, { params }) {
  const task = await db.getById("tasks", params.id)
  return NextResponse.json(task)
}

// BAD: User ID comes from request body, not from auth token
export async function GET(request) {
  const { userId } = await request.json()
  const profile = await db.getById("profiles", userId)
  return NextResponse.json(profile)
}

Limits and configuration

RuleValueNote
Unauthenticated verdictsPOSSIBLE (0.60) or SAFEnever CONFIRMED or LIKELY without credentials
Cross-user readCONFIRMED · 0.95GET only; owner 2xx, anonymous 401/403, attacker 2xx with the owner's body
Cross-user writeCONFIRMED · 0.98 / LIKELY · 0.80PUT and PATCH; a non-auth 4xx (bad body) still shows B got past access control
Ids tried per endpoint8harvested ids first, deduplicated; numeric filter when all harvested ids are numeric
Mutation probes--probe-writesauthenticated when a session exists; canary prefix isitsecure_rb_; original restored
Not probed cross-userPOST, DELETEPOST creates and DELETE destroys; the read-back path covers PUT/PATCH and the gated delete
Supabasefull-table SELECT *User B is also tried against the REST API for a table-wide read
What it deliberately stays quiet about

Without two accounts the scanner reports leads, not findings: it refuses to call a public catalogue an IDOR because it cannot see intent. With two accounts, a resource that an anonymous request can also read is never reported, and a 200 whose body is not the owner's data is never reported. A code-split front end's 200 shell is rejected before any of this runs.