In 2023, a researcher changed a single number in a URL, /api/orders/10021 to /api/orders/10022, and got back a complete order record belonging to someone else. Name, address, items purchased, last four digits of a payment card. No exploit chain, no malware, no clever payload. Just a missing ownership check.
That's IDOR: Insecure Direct Object Reference. In the OWASP API Security Top 10 it's called BOLA, Broken Object Level Authorization, and it's API1:2023. Different name, same failure: the application confirms you're logged in, but never confirms you're allowed to touch the specific object you asked for.
It's arguably the most consistently present vulnerability class in modern web apps and APIs, and it causes more real SaaS data breaches than SQL injection, XSS, and RCE combined, not because it's sophisticated, but because it's invisible to most of the tooling teams rely on by default.
The precise definition
An IDOR exists when three things are all true: the app accepts a user-supplied identifier for an internal object, uses that identifier to fetch the object, and never verifies the requesting user is actually authorized to access that specific object. The vulnerability isn't in the identifier existing or being guessable. It's in that third step, the missing gap between "an ID was supplied" and "this object got returned anyway."
The right security question was never "can the user change the ID." It's "after the ID changes, does the server still check whether this specific authenticated user is allowed to touch this specific object." If the answer is no, you have an IDOR/BOLA.
What it actually looks like, across six different shapes
Sequential IDs. GET /api/orders/10021 returns User A's order legitimately. Swap it to /api/orders/10022, and if that returns User B's order without an ownership check, that's the classic pattern. The sequential IDs aren't the bug, the missing authorization check on top of them is.
BOLA in an API. A vehicle-control API: POST /api/vehicles/ABC123/doors/unlock works fine for the owner. Swap the vehicle ID to one belonging to someone else, and if the server unlocks it anyway, that's API1:2023 by definition, OWASP uses almost this exact example.
UUIDs don't fix it. GET /api/documents/f47ac10b-58cc-4372-a567-0e02b2c3d479 looks unguessable, but User A can still get User B's UUID through a shared link, an API response, a notification, browser history, any legitimate flow. Predictability affects how easily an ID is discovered. Authorization determines whether it can be used. Those are separate properties, and switching ID format only fixes the first one.
File references. GET /static/invoices/10021.pdf swapped to 10022.pdf is the same bug wearing a different hat, applies equally to profile images, exported reports, generated PDFs, chat transcripts.
GraphQL. A mutation like deleteDocument(id: "doc-10022") can delete an object the caller doesn't own just as easily as a REST endpoint can leak one. The identifier's transport (URL path, query param, JSON body, GraphQL variable) is irrelevant. The server still has to authorize the object before acting on it.
Multi-tenant. GET /api/organizations/acme/invoices swapped to /api/organizations/other-company/invoices is the same failure at organizational scale, and it's the most damaging variant because a single missing check can expose an entire company's data instead of one user's.
The taxonomy that actually matters for triage
- Horizontal — same privilege level, different user. The most common type. CVSS roughly 6.5 (read) to 8.8 (write/delete).
- Vertical — a standard user reaches admin or elevated-privilege data by supplying a privileged resource's ID. Roughly 7.5–9.1.
- Blind — the action succeeds but nothing comes back in the response. No visible data exposure, so it's chronically underestimated, but it causes real integrity failures: deleted content, modified state, disrupted workflows, with no trace unless you check the target resource afterward. Roughly 5.0–8.8.
- Second-order — an identifier gets accepted in one step of a workflow, stored, and reused later without re-validating ownership. No automated tool understands multi-step context this well; the endpoint looks completely correct in isolation. Often 7.5–9.5, because it tends to bypass the most sensitive step in a workflow.
- Multi-tenant — cross-organization access. A force multiplier: one horizontal IDOR leaks one user's data, one multi-tenant IDOR can leak an entire org's, potentially thousands of records in a single request. Almost always 8.8–9.5.
-
Mass assignment — the request body includes fields like
user_id,owner_id, orrolethat the client shouldn't be able to set, and the server processes them unfiltered.
Scoring it correctly
Most IDOR findings get under-scored because whoever's scoring focuses on the single HTTP request instead of what it enables at scale. The good news is that almost every parameter is fixed across web/API IDORs, so scoring mostly comes down to one variable: impact.
Fixed CVSS 4.0 parameters for nearly every case: Attack Vector Network, Attack Complexity Low, Attack Requirements None, Privileges Required Low, User Interaction None. Two exceptions: second-order IDORs get Attack Requirements Present, since specific workflow state has to exist first, and unauthenticated IDORs (rare, but they happen) get Privileges Required None, which pushes the score straight to critical.
| Scenario | What changes | CVSS 4.0 |
|---|---|---|
| Read, single low-sensitivity record | VC: Low | ~5.3 |
| Read, single high-sensitivity record (PII/financial) | VC: High | ~6.9 |
| Read, all records for one user | VC: High | ~7.1 |
| Read, all records across all users | VC: High + SC: High | ~8.8 |
| Read, multi-tenant, full org exposure | VC: High + SC: High | ~9.1 |
| Write, modify another user's resource | VI: High | ~8.8 |
| Write, modify an admin-level resource | VI: High + SC: High | ~9.1 |
| Delete, another user's data (blind) | VI: High | ~7.5 |
| Delete, critical business records | VI: High + SC: High | ~8.5 |
| Multi-tenant, any of the above | Always add SC: High | 8.8–9.5 |
Two questions decide almost the entire score: how many records are actually in scope, and can the attacker write or delete, or only read. Write and delete consistently score higher than read at equivalent scope, and multi-tenant always adds SC: High because impact extends beyond the attacker's own org.
Why your existing tools structurally can't catch this
DAST operates at the HTTP request level: send a request, look at the response, compare patterns. It has no concept of ownership. That's not a maturity gap in any particular tool, it's a structural limit of request-level testing without object-ownership context.
Static analysis flags patterns like "identifier from user input used in a query without a visible authorization check nearby," and in practice this produces a false-positive rate that consistently exceeds 50%. Engineers burn time dismissing noise, and real IDORs buried in a complex call chain still get missed, because an authorization check exists somewhere in the codebase, just not the right one at the right level for this specific resource.
The actual test IDOR needs is a runtime, cross-identity question: is this authorization check actually enforced, for this resource type, from this identity, at this point in this specific workflow. That requires an authenticated identity, a resource it owns, a second identity that doesn't own it, and systematic testing of every endpoint with both, comparing results. That's a fundamentally different exercise than anything a single-request scanner does.
Testing methodology, phase by phase
IDOR testing needs at least two accounts with known, distinct resource ownership, and ideally two separate tenants if the app is multi-tenant. From there:
Endpoint discovery. Pull every endpoint from the OpenAPI spec and from JS bundle analysis, since bundles often expose internal paths the spec omits. Flag every endpoint that accepts an object identifier, note whether it's in a path, query param, body, or header, and classify by method, since write/delete IDORs generally outscore read-only ones.
Horizontal testing. Replay Account A's authenticated request using Account B's resource IDs, across every method, not just GET. Test batch endpoints specifically by mixing owned and unowned IDs in one request, batch endpoints frequently check authorization once for the whole collection instead of per item. Test export/download endpoints, teams treat these as secondary and skip authorization review on them constantly. Test search/filter parameters using another user's ID as the filter value.
Vertical testing. Look for admin resource IDs leaking through error messages or metadata, then try accessing them with a standard-user token.
Multi-tenant testing. Find every place org_id/tenant_id shows up (path, query param, body) and try substituting another org's ID in each location independently. Path-based org scoping (/api/orgs/{org_id}/resources) is the most common pattern and the one most often missing validation.
Second-order testing. Map multi-step workflows, create a resource as Account A, then try to resume or complete the workflow as Account B. The common failure: ownership gets checked at creation but never re-checked at resumption.
Blind testing. Hit every DELETE and action-POST endpoint with another user's resource ID. A 204 No Content is not a passing result by itself, the only way to confirm impact is to check the resource afterward as its actual owner and see whether it changed.
Fixing it
The fix is always server-side, object-level authorization, never identifier obscurity.
# VULNERABLE
invoice = Invoice.query.get_or_404(invoice_id)
return jsonify(invoice.to_dict())
The identifier might be valid, but nothing here confirms the authenticated user owns this invoice.
# SECURE
invoice = Invoice.query.filter_by(
id=invoice_id,
user_id=current_user.id
).first_or_404()
return jsonify(invoice.to_dict())
The change that matters isn't the identifier format, it's the ownership constraint applied server-side. A few patterns that generalize: derive ownership exclusively from the authenticated session, never from anything the client claims; make an ownership filter a structural requirement on every query touching user-owned data, not an optional extra; for multi-tenant apps, enforce row-level tenant isolation at the ORM or database layer so it can't be skipped by a careless handler; explicitly filter mass-assignment fields like user_id, owner_id, and role out of client-writable input; and for multi-step workflows, re-validate ownership at every single step, never assume a check from step one still holds by step three.
Testing it manually with Burp
Browse the app authenticated, capture requests carrying anything that looks like an object reference, not just numeric IDs: UUIDs, usernames, slugs, filenames, tenant IDs, GraphQL variables. Send a request for your own object to Repeater and confirm the baseline response. Then change only the object identifier, keep the same auth token, and see what comes back.
Don't trust status code alone. A vulnerable endpoint can return 200 OK with someone else's data, and a properly protected one might also return 200 OK with an error message in the body instead of a 403. Compare status, response body content, whether the object identifier in the response actually matches what was requested, response length against a known-denied baseline, and any side effects (did it modify or delete something).
Confirmation requires all of: User A is authenticated, User A can access Object A, Object B belongs to User B, User A swaps the reference to B, the server returns or modifies Object B, and no additional authorization check intervened. That mismatch between "who's authenticated" and "what they were allowed to touch" is the entire vulnerability.
The UUID misconception, one more time
"We switched to UUIDs so IDOR isn't a problem anymore" is one of the most persistent wrong beliefs in web security. UUIDs prevent enumeration. They do nothing to prevent unauthorized access once an identifier is obtained through some legitimate path, a shared link, a leaked response, a notification email. The fix was never about hiding the identifier. It's ownership verification at the server, full stop, and it's identical work whether the ID is 10022 or a UUID.
Why the blast radius here is different
Most vulnerability classes have a bounded blast radius. XSS affects visitors to one page. A given SQL injection exposes one query's results. IDOR's blast radius scales directly with your user count and data sensitivity, because it's one missing check away from every record that check was supposed to protect. A SaaS app with 10,000 customer records has 10,000 records riding on a single ownership filter. A multi-tenant platform with 500 customer orgs has 500 organizations' worth of data one parameter swap away from any authenticated user who thinks to try it.
It doesn't take exploit code or specialized tooling. It takes patience and arithmetic, and it lives entirely in application logic, which is exactly the layer most default scanning tooling doesn't reach. The only detection method that actually works is authentication-aware, ownership-tracking, multi-identity testing that asks "what if this belonged to someone else" at every endpoint, every method, every workflow step, systematically rather than opportunistically.
CodeAnt AI's penetration testing platform is built around exactly that cross-identity testing model, authenticating as multiple real identities simultaneously and tracking ownership across the full API surface rather than scanning requests in isolation. For the multi-tenant variant specifically, which tends to be the highest-severity flavor of this bug, see CodeAnt's multi-tenant SaaS penetration testing guide.


Top comments (0)