slug: idor-insecure-direct-object-reference-guide description: "IDOR is the most common API vulnerability there is. Where it hides, why UUIDs do not fix it, how to test for it systematically, and the authorization patterns that actually work." canonical: https://www.codeant.ai/blogs/idor-insecure-direct-object-reference-guide date: 2026-09-08
Change a number in a URL. See somebody else's data.
That is the entire vulnerability, and it sits at position one on the OWASP API Security Top 10.
It has stayed there across editions, in an industry that has otherwise made real progress on injection, cryptography, and authentication.
The reason it persists is not that developers do not know about it. It is that the correct fix is architectural, and the incorrect fix is one line, and the incorrect fix looks like it works.
This is a working guide. Where these bugs hide, why obscure identifiers do not help, how to test systematically, and which authorization patterns eliminate the class rather than patching instances.
Where this connects: the exploitation playbooks in CodeAnt AI's pentesting cover this class directly, including horizontal and vertical privilege escalation, identifier rotation, parameter inclusion, and GraphQL alias abuse. The whitebox review traces tainted input from request handlers into authorization decision logic, which is where these defects originate.
What is IDOR?

IDOR stands for Insecure Direct Object Reference.
It occurs when an application uses a user-supplied identifier to look up an object, and fails to verify that the requesting user is authorized to access that specific object. In the CWE taxonomy it maps to CWE-639, Authorization Bypass Through User-Controlled Key.
The word "direct" is the important one. The identifier the client sends maps directly to a record in your data store, with nothing in between checking whether this client should reach that record.
The token is valid. The user is authenticated. The endpoint is one they are allowed to call. And invoice 1042 belongs to user 91.
If the response returns that invoice, you have an IDOR.
Authentication is not authorization
This distinction is the whole bug, so it is worth stating precisely.
Authentication answers who the caller is. Authorization answers what this specific caller may do to this specific object.
Almost every IDOR is an application that solved the first problem thoroughly, with tokens, sessions, refresh flows, and multi-factor, and then assumed it had solved the second.
IDOR, Broken Object Level Authorization, and Broken Function Level Authorization
Three terms describe overlapping ground and get used interchangeably, which causes real confusion in reports.
IDOR is the classical name from the web application era. It describes the mechanism, which is a direct reference used without an access check.
Broken Object Level Authorization is the OWASP API Security Top 10 name, listed as API1:2023. It describes the same defect in API terms. OWASP treats the two as the same thing.
Broken Function Level Authorization is API5:2023 and it is a different bug. Here the attacker reaches an endpoint they should not be able to call at all, rather than reaching the wrong object through an endpoint they are entitled to use.
Object level | Function level | |
|---|---|---|
OWASP ID | API1:2023 | API5:2023 |
Endpoint access | Legitimate | Not legitimate |
What is manipulated | The object identifier | The endpoint or method |
Example | User A reads user B's invoice | A standard user calls an admin route |
Fix location | The data access path | The routing and role layer |
A note on abbreviations. Write Broken Object Level Authorization out in full in any public-facing document.
The three-letter form collides with unrelated terms and makes your writing harder to search and harder to read.
Why IDOR is the most common API vulnerability
Four structural reasons, and none of them are about developer skill.
Endpoints multiply, authorization does not. A REST API exposes an identifier-bearing endpoint for every resource. A team ships forty endpoints in a quarter. The authorization check has to be present in all forty.
It is invisible in normal use. A user browsing the application only ever sends their own identifiers, so the bug never surfaces in manual testing, in QA, or in a demo. Everything works.
Automated scanners cannot detect it reliably. A scanner does not know that invoice 1042 belongs to a different tenant. Detecting the class requires two authenticated sessions and a comparison, which is out of scope for most tooling.
The obvious fix is wrong. Switching sequential integers to UUIDs makes the bug harder to find and does not remove it. More on that below, because it is the single most common mistake in this area.
The four variants you will actually encounter
Horizontal privilege escalation
The classic case. Same role, different owner.
Both callers are ordinary users. The application checks that you are logged in and never checks that document set 91 is yours.
Vertical privilege escalation
Different privilege level reached through an object reference rather than through a route.
The endpoint is one the user is allowed to call, because updating their own profile is legitimate. The object property they are allowed to modify is where the check is missing.
Object property level
The endpoint checks ownership correctly and returns fields the caller should not see.
The front end renders three of those fields. The API returns all seven. OWASP tracks this separately as API3:2023, and it is frequently found in the same audit as the object level bug.
CodeAnt's research team disclosed a live example of this exact pattern in Dolibarr ERP/CRM: CVE-2026-71511 was a redaction bug in the members API that returned every member's password hash to any account allowed to read member records, a field that should never have been serialized in the response at all.
Mass assignment, the write-side equivalent
The mirror image of the previous case. The API binds request body fields to model attributes without an allowlist.
Every field on the model is now writable by anyone who can call the update endpoint.
CodeAnt's disclosures against Dolibarr found this same pattern twice on the write path: CVE-2026-71504 let a request body overwrite fields on the members API that were never meant to be client-settable, and CVE-2026-71509 let ordinary users approve their own expense claims by including an approval field the endpoint should have rejected.
Where IDOR actually hides
Testing only URL path parameters misses most of them. The complete list of locations where a user-controlled object reference reaches a lookup.
Location | Example |
|---|---|
Path parameter |
|
Query string |
|
Request body |
|
HTTP header |
|
Cookie |
|
Nested JSON |
|
Array element |
|
Multipart form field | file upload metadata |
GraphQL variable |
|
GraphQL alias batch | multiple aliased fetches in one request |
WebSocket message |
|
Filename or path |
|
Pre-signed URL parameter | object key in a storage URL |
Webhook callback payload | identifiers echoed back from a third party |
Batch or bulk endpoint | a list of identifiers processed in a loop |
Report or export job | a job that resolves identifiers asynchronously |
Two of those deserve extra attention.
Batch endpoints frequently check authorization on the first element and then process the rest in a loop. Sending your own identifier first and somebody else's second is a real and repeatedly successful technique.
Asynchronous jobs often lose the requesting user's context between enqueue and execution. The worker runs with service credentials and no ownership check.
Why UUIDs are not a fix
This deserves its own section because it is the most common wrong answer, and it is wrong in a specific and demonstrable way.
Replacing /api/invoices/1042 with /api/invoices/8f14e45f-ceea-467a-9575-1a9e0a0a3b2c does not add an authorization check. It makes the identifier harder to guess.
That is security through obscurity, and identifiers leak constantly.
Through your own API. A list endpoint returns identifiers for objects the user can see. A search endpoint returns them. A webhook payload contains them. Any relationship traversal exposes them.
Through shared artifacts. Identifiers appear in exported CSVs, in email links, in support tickets, in screenshots, and in browser history.
Through referrer headers and logs. An identifier in a URL travels to third-party analytics, to error reporting services, and into access logs read by people with no relationship to the object.
Through predictable generation. Version 1 UUIDs encode a timestamp and a MAC address. Sequential or database-generated identifiers presented as opaque strings are frequently ordered. Auto-incrementing values encoded in base64 look opaque and are not.
The correct framing is that unpredictable identifiers are a useful defence in depth measure that reduces enumeration speed and blast radius. They are not an access control.
An application with UUIDs and no ownership check is still vulnerable. It just requires the attacker to obtain an identifier first, and identifiers are obtainable.
The root cause, authorization at the wrong layer
Nearly every IDOR traces back to one architectural decision. The authorization check lives in the controller and the data access lives somewhere else.
That code is correct. It is also fragile in a way that guarantees the next endpoint will be wrong.
The check is a separate statement that a developer must remember to write. It is not enforced by anything.
A new endpoint written by a different person six months later will not have it, and nothing in the system will complain.
A real disclosure that shows exactly this failure mode. CodeAnt AI's security research team found this pattern in Dolibarr ERP/CRM's Third Parties REST API. The route that reads a company's customer-portal accounts runs two checks: does this caller have the right to read companies at all, and does this caller have the right to read this specific company. The sibling route that writes a new portal password runs only the first check, then goes straight to the database keyed on whatever company number is in the URL. A key holding nothing but the create-companies permission could read a
403 Forbiddenon the company it was not allowed to see, then set that same company's portal password one request later and get back200 OK. That is CVE-2026-71505, CVSS 8.1. A near-identical asymmetry on a different Third Parties write route let attackers redirect outbound supplier payments in CVE-2026-71507. Both findings are part of the same "split-brain authorization" pattern CodeAnt walks through in its Dolibarr research pillar: a read route that checks ownership carefully and a write route touching the same rows that does not.
The pattern that eliminates the class
Make the ownership constraint part of the query itself, so an unscoped lookup is not expressible.
The difference is that forgetting the constraint now produces no result rather than the wrong result. The failure mode inverts from silent data exposure to an obvious empty response during development.
Enforcing it structurally
Better still, remove the ability to write the unscoped version.
Scoped repositories. Every data access goes through a repository constructed with the current principal, and the raw model is not importable from request handlers.
Row level security in the database. PostgreSQL enforces the constraint below the application entirely, which means an ORM mistake or a raw query cannot bypass it.
Set app.current_tenant from the authenticated session at the start of every transaction, and every query against that table is filtered whether or not the application remembered to filter it.
Default-deny middleware. Register a check that fails any route lacking an explicit authorization declaration, so a new endpoint without one does not ship.
That fails loudly in development and cannot be forgotten, which is the property the manual check lacks.
How to test for IDOR systematically
Manual testing works and it needs a method, because the bug is invisible without two identities.
Step 1. Establish two accounts in the same role
You need User A and User B, both ordinary users, ideally in different tenants if the application is multi-tenant. A third account with an elevated role helps for the vertical cases.
Capture a valid session for each.
Step 2. Build an identifier inventory
Walk the application as User A and record every identifier that appears anywhere. Response bodies, URLs, hidden form fields, JavaScript bundles, WebSocket frames.
Do the same as User B. You now have two sets of identifiers and two sets of credentials.
Step 3. Cross-test every combination
The core test is a matrix. For each endpoint, send User B's identifier with User A's session.
Request | Expected | IDOR if |
|---|---|---|
A's session, A's object | 200 | not applicable |
A's session, B's object | 403 or 404 | 200 |
No session, A's object | 401 | 200 |
A's session, non-existent object | 404 | 200 or a distinguishable error |
That last row matters more than people expect.
If a non-existent identifier returns 404 and somebody else's identifier returns 403, the difference confirms the object exists, which is an enumeration oracle even when the data is protected.
Return 404 for both.
This is not a theoretical risk. CodeAnt's research team used exactly this kind of yes/no differential to extract Dolibarr data that was never returned directly: CVE-2026-71510 let an attacker infer hidden salary figures and password hashes purely from whether a filtered search query came back empty or not, without the underlying values ever appearing in a response body.
Step 4. Automate the matrix
Doing this by hand across a real API is not feasible. Two approaches.
Burp Suite Autorize replays every request you make as User A using User B's session and flags responses that match. It is the standard tool for this and it is the fastest path to coverage.
A scripted differential gives you repeatability and fits in CI.
Run it against every identifier-bearing endpoint in your OpenAPI specification, and the coverage problem becomes a generation problem rather than a manual one.
Step 5. Test the methods separately
An endpoint frequently checks ownership on GET and not on PATCH or DELETE, because the read path was reviewed and the write path was added later.
Test every method independently. A read-only IDOR is a disclosure. A write IDOR is account takeover, which is precisely what happened in CVE-2026-71505 above: the GET was correctly guarded, the PUT was not.
GraphQL requires a different approach
GraphQL breaks the endpoint-by-endpoint model, because there is one endpoint and the object references are inside the query.
Alias batching
A single request can fetch many objects, and rate limiting or per-request checks see one request.
If authorization is implemented per request rather than per resolver, this walks the entire table in one call.
Nested traversal
The dangerous path is often not the top-level field. It is a relationship two levels down whose resolver nobody thought to protect.
The node interface
Relay-style schemas expose a global node(id: ID!) field that resolves any object by its global identifier. That is a single endpoint returning every object type in your schema.
Note that global identifiers in this pattern are typically base64 of Type:id, which means decoding one tells you the type and the numeric identifier, and encoding a new one is trivial.
The rule for GraphQL. Authorization belongs in every resolver that returns an object, not at the query entry point. A field resolver that assumes its parent was authorized is the standard source of these bugs.
For the complete methodology, including endpoint discovery, introspection, batching payloads, and how white box source review finds resolver-level gaps that black box testing misses, see CodeAnt's GraphQL penetration testing checklist.
Authorization models that scale
Once you accept that per-endpoint checks do not hold, the question becomes which model to adopt.
Model | Decides on | Good for | Limit |
|---|---|---|---|
Role-based | The caller's role | Coarse function-level control | Cannot express per-object ownership |
Attribute-based | Attributes of caller, object, context | Rich conditional policy | Policy sprawl, hard to audit |
Relationship-based | The graph between caller and object | Sharing, nesting, org hierarchies | Needs a dedicated store |
Ownership-scoped queries | The data access path | Simple single-tenant ownership | Does not express sharing well |
Role-based access control alone cannot fix IDOR, which is worth stating plainly. Roles answer function-level questions. Object-level questions need the relationship between this caller and this object.
Relationship-based models follow the design in Google's Zanzibar paper. They store tuples of the form object#relation@subject and answer "may this subject perform this action on this object" as a graph query.
Open implementations include SpiceDB, OpenFGA, and Ory Keto. Policy engines including Open Policy Agent, Cedar, and oso cover the attribute-based side.
The selection matters less than the principle. Authorization decisions should come from one component that every path consults, rather than from a check each developer remembers to write.
Regression testing so it does not come back
Finding your IDORs once is a project. Keeping them out is a test suite.
Two properties make this worth the effort.
The route list is generated from your API specification. A new endpoint appears in the test automatically, rather than when somebody remembers to add it.
The assertion accepts 403 or 404 but not 200, which catches the regression regardless of which error convention the team picked.
This is also the gap between a point-in-time pentest and continuous coverage: a route added the week after your last engagement carries no regression test at all until the next one. CodeAnt's breakdown of continuous versus annual pentesting goes through the attack-surface drift this creates and what closing it actually costs.
Where CodeAnt AI fits
Three concrete points of contact.
Whitebox flow analysis. Static analysis follows tainted input from request handlers into database query construction and into authentication decision logic, producing typed candidates including broken object-level authorization and broken function-level authorization, plus missing-middleware patterns where sensitive routes are mounted without authentication checks.
That last pattern is the function-level half of this article, and it is detectable at source in a way the object-level half often is not.
Typed exploitation. The AI pentesting pipeline maintains a dedicated playbook for this class, covering horizontal and vertical privilege escalation, identifier rotation, parameter inclusion, and GraphQL alias abuse. It runs in black, grey, and white box depth, and in white box mode the same 500-plus agents that read the codebase for the exploitation phase are the ones reasoning about which routes share a data model and where an ownership check was dropped, the exact class of gap CVE-2026-71505 above turned out to be.
The relevant property is that it runs the cross-account matrix rather than pattern-matching for it. Confirming an IDOR requires two sessions and a comparison, which is the step scanners skip.
Proof rather than detection. The deliverable is the extracted records, on the principle that a detected vulnerability is not a confirmed leak. For this class specifically, tenant-isolation violations are tracked as their own data class, meaning proof that one tenant's data is reachable from another tenant's session.

That is the finding that changes a roadmap, and it is the reason "we confirmed access" is a weaker report than "we retrieved 127 records belonging to another tenant," or than the 403-then-200 pair CodeAnt published in CVE-2026-71505.
For an example of what "proof" looks like when the bug is a validated attack path rather than a single endpoint, see CodeAnt's writeup of the Liquid Network hack, where the individual checks along the chain each looked fine in isolation and the exploitable path only showed up once someone walked it end to end.
The IDOR audit checklist
Inventory
Enumerate every identifier-bearing parameter across paths, query strings, bodies, headers, cookies, and WebSocket messages.
Include batch and bulk endpoints, which frequently authorize only the first element.
Include asynchronous jobs, which often lose the requesting principal between enqueue and execution.
Testing
Use two accounts in the same role, in different tenants where applicable.
Run the full matrix of session against object, including the unauthenticated row.
Test each HTTP method independently. Read paths get reviewed and write paths get added later.
Confirm 404 and 403 are indistinguishable, so response codes are not an enumeration oracle.
For GraphQL, test nested resolvers and alias batches, not only top-level fields.
Remediation
Move the ownership constraint into the query, so an unscoped lookup returns nothing.
Route data access through principal-scoped repositories, and make raw models unimportable from handlers.
Enable row level security where your database supports it, as a layer below application logic.
Add default-deny middleware that rejects any route without a declared authorization policy.
Allowlist writable fields explicitly rather than binding request bodies to models.
Do not
Do not treat UUIDs as an access control. They slow enumeration and prevent nothing.
Do not rely on role-based access control alone. Roles answer function-level questions, not object-level ones.
Do not authorize once at the query entry point in GraphQL. Every resolver returning an object needs its own decision.
Where this leaves you
IDOR persists because the standard fix is a check a developer has to remember, and memory is not a control.
Every durable solution has the same shape. Make the unauthorized query impossible to express rather than incorrect to write.
Scope the query, scope the repository, enable row level security, or move the decision into a single component every path must consult.
Then test it with two accounts, on every method, generated from your specification, in CI. That is the difference between finding your IDORs once and not shipping new ones.
Related reading on CodeAnt AI: 3 Types of Penetration Testing: Black Box, White Box, and Gray Box for how testing depth changes what a run like this can find, AI Penetration Testing Methodology for the full phase-by-phase process this class of finding comes out of, and Auditing the Shipped Artifact for how the same authorization-at-the-wrong-layer failure shows up outside the API, in the binaries teams actually ship.


