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.
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.
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.
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.
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.
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
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.
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)
}// GOOD: Verify the resource belongs to the authenticated user
export async function GET(request, { params }) {
const user = await getAuthUser(request)
const task = await db.query("tasks", `id = '${params.id}' AND user_id = '${user.id}'`)
if (!task.length) {
return NextResponse.json({ error: "Not found" }, { status: 404 })
}
return NextResponse.json(task[0])
}// GOOD: Use Supabase RLS instead of application-level checks
// In your migration:
CREATE POLICY "Users can view own tasks" ON tasks
FOR SELECT USING (auth.uid() = user_id);Limits and configuration
| Rule | Value | Note |
|---|---|---|
| Unauthenticated verdicts | POSSIBLE (0.60) or SAFE | never CONFIRMED or LIKELY without credentials |
| Cross-user read | CONFIRMED · 0.95 | GET only; owner 2xx, anonymous 401/403, attacker 2xx with the owner's body |
| Cross-user write | CONFIRMED · 0.98 / LIKELY · 0.80 | PUT and PATCH; a non-auth 4xx (bad body) still shows B got past access control |
| Ids tried per endpoint | 8 | harvested ids first, deduplicated; numeric filter when all harvested ids are numeric |
| Mutation probes | --probe-writes | authenticated when a session exists; canary prefix isitsecure_rb_; original restored |
| Not probed cross-user | POST, DELETE | POST creates and DELETE destroys; the read-back path covers PUT/PATCH and the gated delete |
| Supabase | full-table SELECT * | User B is also tried against the REST API for a table-wide read |
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.