← Blog
October 23, 20269 min readAppSecVulnerability ResearchBug bountyAccess ControlSecurity Engineering

A 200 is not proof: verifying IDOR and broken object level authorization

Changing an object ID and getting an HTTP 200 is where most IDOR reports begin and end — but a 200 is a question, not an answer. A practitioner's guide to confirming a broken-object-level-authorization bug with a controlled two-account test, telling a real cross-tenant exposure apart from your own data echoed back, and scoping blast radius without ever touching a real user's records.

By The Xalgorix Team

The find is almost always trivial. You see a request like GET /api/invoices/4017, you change 4017 to 4016, and the server answers 200 OK with a body full of JSON. A scanner does the same thing a thousand times a minute. The reflex is to write “IDOR” in the report and move on — and that reflex is wrong often enough to get findings bounced, because a 200 is a question, not an answer. The hard, valuable work is proving that the object you just read belongs to someone else, that the application was supposed to stop you, and that it matters.

IDOR, BOLA, and why both names show up

The vulnerability is an access-control failure: the server trusts a client-supplied identifier to decide which record to return or change, and never checks whether the caller is allowed that specific record. PortSwigger defines it plainly — “Insecure direct object references (IDOR) are a type of access control vulnerability” — and notes the term was popularized by its appearance in the OWASP 2007 Top Ten. MITRE catalogues the same weakness as CWE-639, “Authorization Bypass Through User-Controlled Key”, and lists IDOR as an alternate name for it.

“BOLA” is the same bug seen through an API lens. It sits at the very top of the OWASP API Security Top 10 as API1:2023, Broken Object Level Authorization — the list's number-one risk. The names are not perfectly interchangeable: MITRE notes the OWASP IDOR concept is broader, since it also covers path traversal, while BOLA additionally covers objects that are not directly referenced. For a tester the distinction rarely changes the work; for a report, use the term your audience uses — BOLA for API teams, IDOR for classic web.

Why a 200 is not proof

A successful status code tells you the request did not error. It does not tell you whose data came back, or whether access control was ever in play. At least five everyday situations produce a 200 that looks like IDOR and is not:

  • You read your own object. Many IDs you can reach are legitimately yours. A 200 on /invoices/4016 proves nothing if 4016 is also on your account.
  • The server echoed your input. Some endpoints reflect the requested ID and a template back without ever loading the record. The body looks populated; it contains nothing of anyone's.
  • It soft-failed to a 200. A missing or forbidden record sometimes returns 200 with an empty list, a default object, or a redacted stub, instead of a 403/404. That is a status-code smell, not an exposure.
  • You are allowed that object. Shared, published, or org-wide records can be readable by design. Access returning data is not the same as access that should have been denied.
  • A cache or CDN answered. A shared cache keyed on the URL and not the session can serve another user's cached response — a real bug, but a different one, and one you must not mistake for a server-side authorization flaw.
200 OK on another ID
Your own / shared object
Authorized by design — no finding
Echo or soft-fail
No real record returned — not proof
Another tenant's record
Authorization bypassed — the bug
Only one of these branches is the access-control bug you are trying to prove.

The common thread: a 200 is consistent with all three outcomes. To separate them you need a second fact that a status code can never give you — whose record the body actually contains.

The two-account differential test

The clean way to establish that fact is to own both sides of the exchange. Provision two independent accounts that should not be able to see each other — two tenants, two users, on an environment you are authorised to test — and plant a distinctive, unguessable marker in one of them. Then ask, from the other account, for the first account's object, and look for your marker. This mirrors how OWASP's testing guide approaches authorization testing: establish what one role can access, then replay those references as a different role and compare.

Setup (two accounts you control):

  Account A (victim)   create object, note a unique marker:
      POST /api/notes   body: { "title": "MARKER-7f3a9c2e-do-not-share" }
      -> 201 Created    id = 8821, owner = A

  Account B (attacker) authenticate as B, request A's object:
      GET /api/notes/8821          with B's session only

Verdict:

  200 + body contains "MARKER-7f3a9c2e..."   -> CONFIRMED: B read A's record
  403 / 404 / 200 without the marker         -> NOT confirmed (control held, or
                                                 you only saw your own / empty data)

The marker is what turns a plausible response into proof — provided you first confirm, as Account A, that it round-trips through the same read the test uses. If the GET representation drops or transforms title, pick a stable A-only field it does return, so the marker's absence under B is real evidence rather than an artefact of the response shape. A reviewer then does not have to trust that id 8821 belonged to someone else — the unique string only ever existed in Account A, so its appearance under Account B's session is hard evidence of a cross-account disclosure. To pin that specifically on missing object-level authorization at the origin — rather than the shared-cache leak noted earlier, which would produce the same marker — confirm the origin itself served the response, with a verified cache bypass or a request straight to origin; a unique query string or a single response header is not enough to assume the cache was skipped. Keep each marker single-use and attributable, exactly as you would a unique out-of-band token when confirming a blind SSRF.

Plant marker
as account A
Request A's id
as account B
Marker returned
under B's session
Confirmed BOLA
cross-account
Account B presenting its own session and receiving Account A's marked record is the finding.

Read, write, and the privilege axis

Once it is confirmed, classify it precisely, because the label drives the severity. Two axes matter. The first is the action: reading another user's object is bad; a PUT, PATCH or DELETE that mutates or destroys it is usually worse, and a write often needs a second controlled test — change the object as B, then read it back as A to prove the state actually changed. The second axis is privilege: horizontal (another user at your level), which PortSwigger notes is the most common form, or vertical (reaching an admin-only object), which escalates impact sharply.

Keep object-level and function-level failures distinct. Reaching a record you shouldn't is BOLA (API1:2023). Reaching an entire endpoint or operation you shouldn't — an admin route, a privileged mutation — is broken function level authorization (API5:2023). They are adjacent in the same system and often reported together, but they are different claims with different fixes, and conflating them weakens the write-up.

Scoping blast radius without harming anyone

Impact is partly about how many objects are exposed, which tempts testers to enumerate. This is the moment to be disciplined, because the difference between a professional finding and a privacy incident is whether you pulled strangers' data to make your point. You do not need to.

  • Prove with objects you own. Both sides of the differential are your accounts. One confirmed cross-account read is the proof; you do not need a third person's record to establish the pattern.
  • Estimate scope, don't harvest it. Read the ID format as a measure of how easily other references can be discovered, not by itself a count of how many records are exposed: sequential integers make enumeration trivial, while a 128-bit random ID still needs a reference leak — through a list endpoint, a URL or a log — to be practical. Blast radius then comes from how references are exposed and whether authorization varies by owner, state or tenant: a sequential key reachable cross-account points to the rest of the table as the likely blast radius, but one hit proves the control is absent for that object, not for every row. You can characterise that from the ID format and a couple of your own objects, without walking the range.
  • Minimise and stop at proof. If a bounded check is genuinely required, keep it to the smallest number of requests that shows the control is absent, avoid fields that return personal data, and stop the moment the point is made. Respect the scope and rules of engagement of the program you are testing.
Enumerating real users' records to “size” an IDOR can itself be unlawful data access, regardless of intent. On an authorised engagement, two accounts you control and a read of the ID format tell you almost everything a destructive sweep would — without ever holding data that isn't yours.

Writing the finding so an engineer fixes it

A confirmed BOLA writes itself if you captured the right things. The evidence bundle is both requests side by side — A planting the marker, B retrieving it under its own session — the unique marker that ties them, the action and privilege classification, and a scope estimate grounded in the ID format. That package answers the three questions a triager asks: is it real, whose data, and how bad. It also maps cleanly onto risk scoring — an unauthenticated, horizontally-reachable, enumerable read of personal data is a very different number from an authenticated edge case on random IDs, and the reachability you demonstrated is the multiplier that moves a raw base score toward real risk.

The controls that actually hold

The fix is not to hide identifiers; it is to check authorization for the specific object on every request. OWASP's Authorization Cheat Sheet is direct about it.

  • Authorize the object, not the type. OWASP: “Perform access control checks on every request for the specific object or functionality being accessed”. Being allowed to read notes does not mean being allowed to read every note; bind each lookup to the caller's ownership or relationship.
  • Deny by default. The same guidance says an application should “deny access by default,” so a handler that forgets its check fails closed rather than open. Failing open here means serving another tenant's object to a caller who should be denied — not the status code: a deny that answers 200 with no protected data has still denied access, and is a misleading status to clean up rather than the authorization hole itself.
  • Centralise the check. Enforce authorization in one place the whole API routes through, rather than re-implementing it per handler, so a new endpoint cannot ship without it. Prefer relationship- or attribute-based checks that can express “caller owns this object.”
  • Treat random IDs as depth, not the fix. Unguessable UUIDs raise the cost of discovery, but obscuring a reference is not an access control; an ID that leaks in a URL, a referrer header, or an export is suddenly usable. Pair unpredictable IDs with a real server-side check.

Proof, not a 200

Access-control bugs are the textbook case for exploit-verified testing: they are easy to flag and easy to get wrong, and the only thing that separates a real cross-tenant exposure from a 200 on your own data is evidence. A finding that carries its proof — two requests, one marker, a clear owner — is one an engineer fixes this sprint, not one that rots at the bottom of a triage queue with the false positives.

It is worth being honest about where automation reaches its limit here. A tool can diff two authenticated sessions and spot a marker crossing between them, but it cannot invent the business meaning of whether B should see A's note — it needs two genuinely provisioned accounts and an understanding of the tenancy model to test at all, which is more setup than a single-session scanner wants to admit. And the same restraint a careful human shows — proving with objects you own, never harvesting strangers' data — has to be built into any scanner you would trust to do this unsupervised. Xalgorix works its 22-phase methodology toward exactly that bar: confirm the authorization break with a controlled, attributable request, and report it only when it can show whose object it reached.

References

Ready to see it prove a bug?

Start scanning on Cloud →