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. For a shorter primer on the variants, CVSS scoring, and detection basics, see What is an IDOR Vulnerability?
IDOR is the asymmetry between attacker and defender in its purest form. An attacker needs to find one endpoint where the ownership check was skipped. You need every endpoint, across every service, to have that check written correctly, and keep it that way through every deploy.
That is not a fair fight to run manually, which is the actual argument for testing the class the same way an attacker would: continuously, and from both outside the API and inside the code that serves it.
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 prevention is one part of a broader secure development approach. For the wider set of secure coding best practices, including authentication, authorization, input validation, and secure handling of application data, see our practical guide.
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 |
Why Broken Object-Level Authorization Remains So Common
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 IDOR and Authorization Bugs 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.
Leak vector | How the identifier escapes |
|---|---|
Your own API | List endpoints return identifiers for objects the user can see. Search endpoints return them. Webhook payloads contain them. Any relationship traversal exposes them. |
Shared artifacts | Identifiers appear in exported CSVs, email links, support tickets, screenshots, and browser history. |
Referrer headers and logs | An identifier in a URL travels to third-party analytics, error reporting services, and access logs read by people with no relationship to the object. |
Predictable generation | Version 1 UUIDs encode a timestamp and a MAC address. Sequential or database-generated IDs presented as opaque strings are often ordered. Auto-incrementing values encoded in base64 look opaque but are not. |
Unpredictability reduces enumeration. It does not establish authorization. That is the whole correction this section is making, and it is worth holding onto as a single sentence, because "we use UUIDs" is the single most common wrong answer to "how do you prevent IDOR."
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 Forbidden on the company it was not allowed to see, then set that same company's portal password one request later and get back 200 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.
The four approaches in this section are not interchangeable options. They are increasing levels of enforcement, and the honest way to compare them is by what happens the moment a developer forgets the check.
Approach | What happens if the developer forgets |
|---|---|
Controller check | Silent vulnerability. The wrong object is returned. |
Scoped query | Empty result. Wrong, but visibly wrong in development. |
Scoped repository | The unscoped call is not importable. Forgetting is not possible. |
Row level security | The database blocks it regardless of what the application code does. |
Read down that table and the article's central claim is the table: each row makes the unauthorized query harder to express, not just harder to get past.
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.
Where appropriate, return the same externally observable response for unauthorized and nonexistent objects. The property you actually want is that object existence is not disclosed by response shape, not a universal rule that the status code must specifically be 404.
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.
What actually counts as a finding. Changing the identifier in a request is not itself an IDOR. It is the setup. A confirmed finding is attacker-controlled identity, plus another user's object, plus an unauthorized response or action, all three together. Worth stating plainly, because it is the difference between a real report and a scanner flagging every parameter that looks like an ID.
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.
How CodeAnt AI Catches IDOR and BOLA Before Attackers Do
Most security tooling stops at one stage: it flags a suspicious pattern, probes an endpoint, or assigns a severity. CodeAnt AI covers all three and connects them, so a finding in code and a finding at runtime strengthen each other instead of arriving as two unrelated alerts.
1. Find it in the code
Static analysis traces tainted input from request handlers into database query construction and authentication decision logic. It produces typed candidates for broken object-level authorization (BOLA) and broken function-level authorization (BFLA). It also flags missing-middleware patterns, where sensitive routes are mounted without an authentication check.
That last pattern matters for this article. It is the function-level half of the problem, and unlike the object-level half, it can be reliably detected at source.

2. Exploit it across accounts
The AI pentesting pipeline runs a dedicated playbook for this class: horizontal and vertical privilege escalation, identifier rotation, parameter inclusion, and GraphQL alias abuse, at black, grey, and white box depth.
The difference is that it runs the cross-account matrix instead of pattern-matching for it. Confirming an IDOR takes two sessions and a comparison of what each can reach, which is exactly the step scanners skip. In white box mode, the pipeline reasons about the same gap CVE-2026-71505 turned out to be: a route pair where one side checks ownership and the other does not.

Why the two run together
Code analysis and pentesting are not separate checks. The same route-pair pattern gets caught from whichever direction it is approached, and each confirmed instance sharpens what the model looks for elsewhere. None of a customer's code or data leaves their account. Only the verified, externally reachable shape of the pattern carries forward.
3. Prove it with the records
A detected vulnerability is not a confirmed leak, so the deliverable is the extracted records themselves. For this class, tenant-isolation violations are tracked as their own data class: proof that one tenant's data is reachable from another tenant's session.
Object-level authorization findings are tagged CWE-639 and sit next to the file path and line where the ownership check is missing, rather than appearing as a generic "vulnerability detected" alert.
That specificity is the point. "We retrieved 127 records belonging to another tenant" is the report that changes a roadmap. "We confirmed access" is not, and neither is a severity label on its own. It is the same argument the 403-then-200 pair in CVE-2026-71505 makes.

For what proof looks like against a full attack path rather than a single endpoint, see CodeAnt's breakdown of the Liquid Network hack. Every individual check along that chain looked fine in isolation; the exploitable path only appeared 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.
Stop Relying on Memory. Make the Unauthorized Query Impossible
IDOR survives code review, audits, and scanners for one reason: the standard fix is a check a developer has to remember to write, on every route, every time. Memory is not a control.
Every durable fix shares the same shape. It makes the unauthorized query impossible to express, rather than merely incorrect to write. In practice, that means one of four moves:
Scope the query to the caller's user or tenant at the point it is built, so there is no unscoped version to call by accident.
Scope the repository so data access methods cannot be invoked without an owner.
Enable row-level security so the database enforces ownership even when the application forgets.
Centralize the decision in a single authorization component that every request path must consult.
Then prove it holds. Test with two accounts, on every HTTP method, with cases generated from your API specification, in CI. That is the difference between finding your IDORs once and never shipping new ones.
Where to start this week
Pick the three endpoints that return your most sensitive objects: invoices, user records, tenant settings. Log in as two different users, swap the identifiers between sessions, and try every method the route accepts, not just GET. If any request returns a 200 with the other user's data, or a 403 on one route and a 200 on its sibling, you have found the same gap CVE-2026-71505 did.
Doing that by hand across three endpoints takes an afternoon. Doing it across every route, every method, and every release is what CodeAnt AI's pentesting automates: it runs the full cross-account matrix, traces the finding back to the file and line missing the ownership check, and hands you the extracted records as proof. Book an AI pentest →
Related reading
3 Types of Penetration Testing: Black Box, White Box, and Gray Box: how testing depth changes what a run like this can find.
AI Penetration Testing Methodology: the phase-by-phase process this class of finding comes out of.
Auditing the Shipped Artifact: how the same authorization-at-the-wrong-layer failure shows up outside the API, in the binaries teams actually ship.


