AI Code Review

Finding and Fixing Insecure Direct Object References via IDOR

Amartya | CodeAnt AI Code Review Platform
Sonali Sood

Founding GTM, CodeAnt AI

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.

{
  "id": 77,
  "email": "user@example.com",
  "internal_risk_score": 0.82,
  "account_manager_notes": "flagged for review",
  "stripe_customer_id": "cus_XXXX"
}
{
  "id": 77,
  "email": "user@example.com",
  "internal_risk_score": 0.82,
  "account_manager_notes": "flagged for review",
  "stripe_customer_id": "cus_XXXX"
}
{
  "id": 77,
  "email": "user@example.com",
  "internal_risk_score": 0.82,
  "account_manager_notes": "flagged for review",
  "stripe_customer_id": "cus_XXXX"
}

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.

# vulnerable, binds whatever arrives
user.update(**request.json)

# an attacker sends
{"name": "New Name", "is_admin": true, "account_balance": 999999}
# vulnerable, binds whatever arrives
user.update(**request.json)

# an attacker sends
{"name": "New Name", "is_admin": true, "account_balance": 999999}
# vulnerable, binds whatever arrives
user.update(**request.json)

# an attacker sends
{"name": "New Name", "is_admin": true, "account_balance": 999999}

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

/api/orders/1042

Query string

/api/export?account_id=1042

Request body

{"invoice_id": 1042}

HTTP header

X-Account-Id: 1042

Cookie

tenant_id=1042

Nested JSON

{"filter": {"owner": {"id": 1042}}}

Array element

{"ids": [1042, 1043, 1044]}

Multipart form field

file upload metadata

GraphQL variable

query($id: ID!)

GraphQL alias batch

multiple aliased fetches in one request

WebSocket message

{"subscribe": "account:1042"}

Filename or path

/files/download?name=../../other/report.pdf

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.

# looks opaque, is not
import base64
base64.b64encode(b"1042")     # b'MTA0Mg=='
base64.b64encode(b"1043")     # b'MTA0Mw=='
# looks opaque, is not
import base64
base64.b64encode(b"1042")     # b'MTA0Mg=='
base64.b64encode(b"1043")     # b'MTA0Mw=='
# looks opaque, is not
import base64
base64.b64encode(b"1042")     # b'MTA0Mg=='
base64.b64encode(b"1043")     # b'MTA0Mw=='

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.

# controller
@app.get("/api/invoices/<invoice_id>")
@require_auth
def get_invoice(invoice_id):
    invoice = Invoice.query.get(invoice_id)      # no ownership constraint
    if invoice.user_id != current_user.id:        # check bolted on afterwards
        abort(403)
    return jsonify(invoice.serialize())
# controller
@app.get("/api/invoices/<invoice_id>")
@require_auth
def get_invoice(invoice_id):
    invoice = Invoice.query.get(invoice_id)      # no ownership constraint
    if invoice.user_id != current_user.id:        # check bolted on afterwards
        abort(403)
    return jsonify(invoice.serialize())
# controller
@app.get("/api/invoices/<invoice_id>")
@require_auth
def get_invoice(invoice_id):
    invoice = Invoice.query.get(invoice_id)      # no ownership constraint
    if invoice.user_id != current_user.id:        # check bolted on afterwards
        abort(403)
    return jsonify(invoice.serialize())

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.

# scoped at the data layer, ownership is a query condition
@app.get("/api/invoices/<invoice_id>")
@require_auth
def get_invoice(invoice_id):
    invoice = Invoice.query.filter_by(
        id=invoice_id,
        user_id=current_user.id      # part of the lookup, not a follow-up check
    ).first_or_404()
    return jsonify(invoice.serialize())
# scoped at the data layer, ownership is a query condition
@app.get("/api/invoices/<invoice_id>")
@require_auth
def get_invoice(invoice_id):
    invoice = Invoice.query.filter_by(
        id=invoice_id,
        user_id=current_user.id      # part of the lookup, not a follow-up check
    ).first_or_404()
    return jsonify(invoice.serialize())
# scoped at the data layer, ownership is a query condition
@app.get("/api/invoices/<invoice_id>")
@require_auth
def get_invoice(invoice_id):
    invoice = Invoice.query.filter_by(
        id=invoice_id,
        user_id=current_user.id      # part of the lookup, not a follow-up check
    ).first_or_404()
    return jsonify(invoice.serialize())

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.

class InvoiceRepository:
    def __init__(self, principal):
        self._principal = principal

    def _base(self):
        return Invoice.query.filter_by(tenant_id=self._principal.tenant_id)

    def get(self, invoice_id):
        return self._base().filter_by(id=invoice_id).first()

    def list(self, **filters):
        return self._base().filter_by(**filters).all()
class InvoiceRepository:
    def __init__(self, principal):
        self._principal = principal

    def _base(self):
        return Invoice.query.filter_by(tenant_id=self._principal.tenant_id)

    def get(self, invoice_id):
        return self._base().filter_by(id=invoice_id).first()

    def list(self, **filters):
        return self._base().filter_by(**filters).all()
class InvoiceRepository:
    def __init__(self, principal):
        self._principal = principal

    def _base(self):
        return Invoice.query.filter_by(tenant_id=self._principal.tenant_id)

    def get(self, invoice_id):
        return self._base().filter_by(id=invoice_id).first()

    def list(self, **filters):
        return self._base().filter_by(**filters).all()

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.

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.current_tenant')::uuid)

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.current_tenant')::uuid)

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.current_tenant')::uuid)

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.

@app.before_request
def require_declared_authorization():
    endpoint = app.view_functions.get(request.endpoint)
    if endpoint and not getattr(endpoint, "_authz_declared", False):
        abort(500, "endpoint has no authorization policy")
@app.before_request
def require_declared_authorization():
    endpoint = app.view_functions.get(request.endpoint)
    if endpoint and not getattr(endpoint, "_authz_declared", False):
        abort(500, "endpoint has no authorization policy")
@app.before_request
def require_declared_authorization():
    endpoint = app.view_functions.get(request.endpoint)
    if endpoint and not getattr(endpoint, "_authz_declared", False):
        abort(500, "endpoint has no authorization policy")

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.

import requests, itertools

SESSIONS = {
    "A": {"Authorization": "Bearer <token-a>"},
    "B": {"Authorization": "Bearer <token-b>"},
}
OBJECTS = {"A": ["1042", "1043"], "B": ["2091", "2092"]}
ENDPOINT = "https://api.example.com/api/invoices/{id}"

for actor, owner in itertools.product(SESSIONS, OBJECTS):
    if actor == owner:
        continue                              # same-owner is the control
    for object_id in OBJECTS[owner]:
        r = requests.get(ENDPOINT.format(id=object_id),
                         headers=SESSIONS[actor])
        if r.status_code == 200:
            print(f"IDOR: session {actor} read {owner}'s object {object_id}")
import requests, itertools

SESSIONS = {
    "A": {"Authorization": "Bearer <token-a>"},
    "B": {"Authorization": "Bearer <token-b>"},
}
OBJECTS = {"A": ["1042", "1043"], "B": ["2091", "2092"]}
ENDPOINT = "https://api.example.com/api/invoices/{id}"

for actor, owner in itertools.product(SESSIONS, OBJECTS):
    if actor == owner:
        continue                              # same-owner is the control
    for object_id in OBJECTS[owner]:
        r = requests.get(ENDPOINT.format(id=object_id),
                         headers=SESSIONS[actor])
        if r.status_code == 200:
            print(f"IDOR: session {actor} read {owner}'s object {object_id}")
import requests, itertools

SESSIONS = {
    "A": {"Authorization": "Bearer <token-a>"},
    "B": {"Authorization": "Bearer <token-b>"},
}
OBJECTS = {"A": ["1042", "1043"], "B": ["2091", "2092"]}
ENDPOINT = "https://api.example.com/api/invoices/{id}"

for actor, owner in itertools.product(SESSIONS, OBJECTS):
    if actor == owner:
        continue                              # same-owner is the control
    for object_id in OBJECTS[owner]:
        r = requests.get(ENDPOINT.format(id=object_id),
                         headers=SESSIONS[actor])
        if r.status_code == 200:
            print(f"IDOR: session {actor} read {owner}'s object {object_id}")

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.

query {
  a: invoice(id: "1042") { id total customer { email } }
  b: invoice(id: "1043") { id total customer { email } }
  c: invoice(id: "1044") { id total customer { email } }
}
query {
  a: invoice(id: "1042") { id total customer { email } }
  b: invoice(id: "1043") { id total customer { email } }
  c: invoice(id: "1044") { id total customer { email } }
}
query {
  a: invoice(id: "1042") { id total customer { email } }
  b: invoice(id: "1043") { id total customer { email } }
  c: invoice(id: "1044") { id total customer { email } }
}

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.

query {
  myOrder(id: "1042") {          # authorized correctly
    customer {                    # resolver inherits nothing
      paymentMethods {            # returns another user's cards
        last4
        billingAddress
      }
    }
  }
}
query {
  myOrder(id: "1042") {          # authorized correctly
    customer {                    # resolver inherits nothing
      paymentMethods {            # returns another user's cards
        last4
        billingAddress
      }
    }
  }
}
query {
  myOrder(id: "1042") {          # authorized correctly
    customer {                    # resolver inherits nothing
      paymentMethods {            # returns another user's cards
        last4
        billingAddress
      }
    }
  }
}

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.

query { node(id: "SW52b2ljZToxMDQy") { ... on Invoice { total } } }
query { node(id: "SW52b2ljZToxMDQy") { ... on Invoice { total } } }
query { node(id: "SW52b2ljZToxMDQy") { ... on Invoice { total } } }

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.

import pytest

# every route that takes an object identifier, generated from your OpenAPI spec
OBJECT_ROUTES = [
    ("GET",    "/api/invoices/{id}"),
    ("PATCH",  "/api/invoices/{id}"),
    ("DELETE", "/api/invoices/{id}"),
    ("GET",    "/api/users/{id}/documents"),
]

@pytest.mark.parametrize("method,route", OBJECT_ROUTES)
def test_cross_tenant_access_is_denied(client, method, route, user_a, user_b_object):
    response = client.open(
        route.format(id=user_b_object.id),
        method=method,
        headers=user_a.auth_headers,
    )
    assert response.status_code in (403, 404), (
        f"{method} {route} leaked object {user_b_object.id} across tenants"
    )
import pytest

# every route that takes an object identifier, generated from your OpenAPI spec
OBJECT_ROUTES = [
    ("GET",    "/api/invoices/{id}"),
    ("PATCH",  "/api/invoices/{id}"),
    ("DELETE", "/api/invoices/{id}"),
    ("GET",    "/api/users/{id}/documents"),
]

@pytest.mark.parametrize("method,route", OBJECT_ROUTES)
def test_cross_tenant_access_is_denied(client, method, route, user_a, user_b_object):
    response = client.open(
        route.format(id=user_b_object.id),
        method=method,
        headers=user_a.auth_headers,
    )
    assert response.status_code in (403, 404), (
        f"{method} {route} leaked object {user_b_object.id} across tenants"
    )
import pytest

# every route that takes an object identifier, generated from your OpenAPI spec
OBJECT_ROUTES = [
    ("GET",    "/api/invoices/{id}"),
    ("PATCH",  "/api/invoices/{id}"),
    ("DELETE", "/api/invoices/{id}"),
    ("GET",    "/api/users/{id}/documents"),
]

@pytest.mark.parametrize("method,route", OBJECT_ROUTES)
def test_cross_tenant_access_is_denied(client, method, route, user_a, user_b_object):
    response = client.open(
        route.format(id=user_b_object.id),
        method=method,
        headers=user_a.auth_headers,
    )
    assert response.status_code in (403, 404), (
        f"{method} {route} leaked object {user_b_object.id} across tenants"
    )

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.

FAQs

What is an IDOR vulnerability?

What is the difference between IDOR and Broken Object Level Authorization?

Do UUIDs prevent IDOR?

How do you test for IDOR?

Why does role-based access control not fix IDOR?

Start Your 14-Day Free Trial

AI code reviews, security and quality trusted by modern engineering teams.

Table of Content
No headings found on page
Ship clean & secure code faster

Get Pentest Report

NO CC REQUIRED