Code Security

Finding and Fixing Insecure Direct Object References via IDOR

Amartya | CodeAnt AI Code Review Platform
Sonali Sood

Founding GTM, CodeAnt AI

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.

GET /api/invoices/1042 HTTP/1.1
Authorization: Bearer <valid token for user 77

GET /api/invoices/1042 HTTP/1.1
Authorization: Bearer <valid token for user 77

GET /api/invoices/1042 HTTP/1.1
Authorization: Bearer <valid token for user 77

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.

GET /api/users/77/documents   200, your documents
GET /api/users/91/documents   200, somebody else's documents
GET /api/users/77/documents   200, your documents
GET /api/users/91/documents   200, somebody else's documents
GET /api/users/77/documents   200, your documents
GET /api/users/91/documents   200, somebody else's documents

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.

PATCH /api/users/77
{"role": "admin"}
PATCH /api/users/77
{"role": "admin"}
PATCH /api/users/77
{"role": "admin"}

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.

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.

# 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=='

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.

# 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.

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.

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.

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

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