Why didn't the API scan find the IDOR? Testing broken object-level authorisation with two identities

DedSec Application Security ·

Consider an authenticated dynamic scan of an invoicing API. It logs in, crawls every route the client calls, fuzzes every parameter it finds, and reports no access-control findings. Then suppose a bug-bounty report arrives: change the invoice ID in GET /v1/invoices/inv_8f2c1e to one from another account, and the API returns that account's invoice.

Nothing was wrong with the scan. It ran, its session stayed valid, and it sent requests to the vulnerable endpoint. It reported nothing because it had no way to recognise the bug. This post explains why, and what testing for it actually takes.

The sources are the OWASP API Security Top 10 2023, three OWASP cheat sheets (Authorization, Authorization Testing Automation, and Insecure Direct Object Reference Prevention), RFC 9110 for HTTP status semantics, RFC 9562 for UUIDs, and the PostgreSQL documentation on row-level security.

What the flaw is

OWASP lists Broken Object Level Authorization first in its API Security Top 10 2023, as API1:2023. Attackers exploit it "by manipulating the ID of an object that is sent within the request", and the requirement it states is simple: "Every API endpoint that receives an ID of an object, and performs any action on the object, should implement object-level authorization checks." The same flaw is older than the API list under the name Insecure Direct Object Reference (IDOR), and CWE files it as CWE-639, Authorization Bypass Through User-Controlled Key.

OWASP's IDOR Prevention Cheat Sheet breaks it into three ingredients: an object (an account, document, ticket or invoice), a reference to that object (an ID, UUID, account number, token or slug), and "a missing object-level authorization check". The first two are present in every API that has data. The third is the bug.

OWASP also explains why APIs are prone to it: the server "usually does not fully track the client's state, and instead, relies more on parameters like object IDs, that are sent from the client to decide which objects to access." The client says which object it wants, and nothing checks whether this client may have it.

Why a scanner has no oracle

Automated vulnerability detection needs an oracle: some property of the response that is different when the bug is present. Injection has several. A database error string, a payload reflected unescaped, a response delayed by exactly the injected sleep, or a DNS lookup to a unique callback host, which the post on out-of-band testing covers for the cases where the response shows nothing. Each of these can be judged from the traffic alone.

BOLA has no such property. Here is the request from the report, made twice:

The first response is correct and the second is a breach, yet they are byte-for-byte the same. What makes the second one wrong is a fact that never appears in the HTTP exchange: inv_8f2c1e belongs to Acme, and B works for Globex. A scanner logged in as B sees a well-formed 200 carrying a plausible invoice, which is what a correct API returns when B asks for B's own invoice.

Consider what a single-identity scanner can do with that endpoint:

OWASP's risk table rates API1:2023 detectability as "Easy", which seems to contradict all this. It does not. OWASP's reasoning is that "the server response is usually enough to understand whether the request was successful". That holds for an attacker, who knows the object is not theirs because they took its ID from somewhere else. Success is visible in the response. Whether that success was authorised is not, unless the tester knows who owns the object.

So testing for BOLA needs three things the traffic does not contain: at least two identities, a record of which objects each one owns, and a comparison of what each identity gets for the same object.

Where object references hide

Before testing, list every place a request can name an object. OWASP's own list is "the request target (path or query string parameters), request headers, or even as part of the request payload". In practice:

Why UUIDs are not the fix

Replacing sequential integers with random identifiers stops enumeration. It does not stop an attacker who already knows an identifier, and identifiers leak in more places than their randomness suggests:

OWASP's API1 prevention list does include preferring "random and unpredictable values as GUIDs for records' IDs", and the IDOR cheat sheet frames that correctly: as defence in depth, because "even with complex identifiers, access control checks are essential." Random IDs make a test harder to write by guessing. They make BOLA no less real for someone who obtained an ID honestly.

Object, function and property: three different questions

The 2023 list separates three authorisation failures that are easy to merge under "broken access control". They need different test inputs, so it is worth keeping them apart.

One request can fail all three. Take PATCH /v1/invoices/inv_8f2c1e with body {"status": "paid", "tenant_id": "t_globex"} sent by a Globex viewer. Viewers should not update invoices at all (function). This invoice is Acme's (object). Nobody should set tenant_id through the API (property). A test suite built only around roles catches the first and misses the other two.

Reads are the visible half

For a read, the leak is in the response. For a write or delete it may not be:

The oracle for a write is therefore a side effect, not the response. OWASP's API3 entry uses the phrase "side-effect analysis" for the property case, and it applies here. Snapshot the target object as its owner, replay the write as the other identity, and read the object again as its owner. If it changed, the check failed, whatever status code the attacker received. This needs disposable fixture objects in a test environment, not production data, which is a further reason a general scanner does not attempt it.

Building a two-identity harness

The IDOR Prevention Cheat Sheet describes the core procedure: create User A and User B, create objects owned by each, authenticate as A and try to access B's objects by changing references, and verify denial "regardless of whether the object identifier is predictable or unguessable". It adds that this applies to "read, create, update, delete, export, and administrative actions." The Authorization Testing Automation Cheat Sheet adds the piece that makes it repeatable: write the authorisation matrix down in a machine-readable file and drive integration tests from it, so that a failing test names the exact combination that broke.

A harness that does this has five inputs.

1. Identities. At least two users in different tenants, which tests the tenant boundary. Add two users in the same tenant with different roles or team membership, because ownership within a tenant is a separate rule. Add an unauthenticated client. Each identity gets its own credentials, refreshed by the harness.

2. Fixtures with canaries. Each identity creates its own objects through the API at the start of a run: an invoice, a document, a comment, an export. Every text field it controls gets a unique marker, such as "canary-acme-inv-01". Canaries are what make the comparison reliable. Searching a response for another identity's canary is less noisy than diffing whole bodies, which differ anyway in timestamps, request IDs and pagination cursors.

3. An ownership map. Built from fixture creation: object ID, type, owning identity, tenant, and anyone it is explicitly shared with. This is the fact the scanner lacked.

4. An endpoint inventory with reference locations. Every operation, and where in the request each object reference sits: path, query, body field, header or GraphQL argument. An OpenAPI document is a starting point. Recorded traffic from the client is a better one, because it includes the fields the specification forgot.

5. Expected outcomes. For each operation, object and identity: allowed or denied. Most rows are derived ("owner may, other tenant may not"); the exceptions, such as shared and public objects, are listed explicitly.

The procedure is record and replay. Exercise the application as A and record each request that names one of A's objects. Replay every recorded request with B's credentials and nothing else changed, so the object references stay A's. Then do the reverse with B's recordings. A sketch with curl:

A returning 200 with its canary is the control. B returning 200 with Acme's canary is the finding. The general loop, in pseudo-code:

Three details decide whether the results mean anything.

Run positive controls. Each identity must succeed on its own objects in the same run, immediately before the negative test. Without that, a denial might mean the check works, or it might mean the token expired, the fixture was never created, or a gateway is rejecting everything. The post on "the scan passed" versus "the scan ran" describes an authenticated scan that spends its second half testing a login page, and an authorisation harness is just as vulnerable to it: an expired token produces a perfect column of denials.

Substitute every reference, not just the path. For an endpoint that takes an ID in both the path and the body, test each one separately, and test them disagreeing. A handler might authorise the path ID and then act on the body ID, and replaying an unmodified recording never exercises it.

Treat 5xx as unresolved. A 500 might mean the lookup failed safely, or that the object was loaded and the failure happened afterwards, in rendering or in a side effect that already ran. For a write, check the side effect before calling it a pass.

Run the harness in CI against an ephemeral environment on every change that touches a route, a model or the authorisation code. The Authorization Testing Automation Cheat Sheet's argument for automating is that authorisation problems arrive "because features are added/modified in updated releases without determining their effect on the application's authorizations". A new endpoint without a row in the matrix should fail the build, because an operation nobody has classified is a gap in the test suite.

403 or 404

Both are denials, and the harness should accept both. Which one the API returns is still a design decision with security consequences.

RFC 9110 defines 403 as the server understanding the request and refusing to fulfil it, and adds: "An origin server that wishes to 'hide' the current existence of a forbidden target resource MAY instead respond with a status code of 404 (Not Found)." If the API returns 403 for an object that exists but belongs to someone else, and 404 for an ID that does not exist, then the status code answers the question "does this object exist?" for any caller. That is an existence oracle. It confirms which leaked or guessed identifiers are live, can reveal whether an account, email or order number exists, and helps an attacker choose targets for other attacks.

The harness can check this directly. For each object endpoint, compare the response for another tenant's real object with the response for a random identifier that never existed. Apart from fields that always vary, such as a request ID, the two should be identical: same status, same body and headers, and no consistent difference in timing.

The rule is not "always 404". Inside a tenant, where a user can legitimately see that an object exists (it appears in their list, but they may not edit it), a 403 is accurate and more useful to the client. What matters is that the choice follows the authorisation model: 404 wherever existence is itself confidential, which includes any cross-tenant request, and 403 only where the caller is already allowed to know the object exists.

The fix: put the check in the query

The vulnerable handler loads by ID alone. In ORM pseudo-code:

An obvious first fix is to check after fetching:

That works for this handler, and it shows the shape BOLA bugs take: correct where someone remembered it, missing wherever someone forgot. It has to be repeated in every handler, for every operation, and the list, update, delete, export and batch paths are separate code that each need their own copy. It also invites the write-first ordering described above, and the 403-versus-404 split, because the code knows the object exists before it decides.

The structural fix is to make ownership part of the lookup, so that another tenant's object is not found rather than found and rejected:

For writes, put the same predicate in the statement and use the affected-row count:

The IDOR cheat sheet's Rails example shows the same idea in one line: Project.find(params[:id]) searches all projects, while @current_user.projects.find(params[:id]) searches only the current user's.

To make it the default rather than a habit:

Tenant scoping is the coarse boundary: a failure there exposes another organisation's data. Per-user rules inside a tenant still need a policy. Putting both in one place makes them reviewable, and makes the harness's expected outcomes something you can derive from code rather than guess.

Why static analysis does not flag it either

A missing predicate is not a taint flow. The vulnerable lookup passes a request value into a parameterised query, which is exactly what injection rules want to see. No dangerous sink is reached and nothing is concatenated. What is wrong is an absent comparison, and whether it is required depends on which tables are tenant-scoped, which is a design fact. The threat-modelling post makes the same argument about a worker that trusts the tenant ID in a queue message.

What static analysis can enforce is the structure. Once there is a scoped repository, a custom rule can flag any direct query against a tenant-scoped model outside it, or any handler that reads a tenant identifier from a header. Those rules check shape, not correctness, but they turn "remember the check" into "use the only path that has it".

Catch it at design review

By the time a harness finds BOLA, the vulnerable pattern may already be in many handlers, written by different people. Moving to a scoped repository then means migrating every handler. At design time it is a decision about one interface.

The artefact that prevents it is an authorisation model, specified per resource before the endpoints exist. For each resource type:

That table does three jobs. It specifies what the data-access layer must enforce. It is the authorisation matrix the harness reads its expected outcomes from. And it gives a reviewer something concrete to argue with, such as "why can a viewer export?", while changing the answer is still cheap. The threat-modelling post walks through a feature in which the tenant boundary runs through a queue, a worker and a webhook. Those are the indirect paths an endpoint-by-endpoint model misses.

Where AppSecD fits

In AppSecD's view of the lifecycle, threat modelling is the design stage: some vulnerabilities are not a line of code but a design decision nobody reviewed, such as an authorisation model assumed rather than specified. Components, trust boundaries and data flows are mapped before the code exists, so the controls that matter are identified while they are still cheap to add. Its static analysis runs custom rule packs alongside OWASP Top 10 and CWE Top 25 rules, and policy-driven gating blocks a pull request or commit that introduces a violation. Its dynamic and API testing runs Nuclei, Dalfox, SQLMap, Commix, Katana, Arjun, FFuf and Interactsh for out-of-band detection, plus more than 150 native checks and API discovery across more than 50 web frameworks, against the deployed application.

None of that is a claim that AppSecD tests object-level authorisation across identities, or that it ships rules that enforce a scoped repository or find a missing ownership predicate. Everything in this post about why single-identity scanning cannot tell an authorised 200 from an unauthorised one applies to any dynamic scanner that tests with one identity. The per-resource authorisation model, the two-identity harness, the ownership map, the canaries and the side-effect checks are general practice: something you build and own against your own authorisation model.

A checklist

  1. Write the authorisation model per resource before building the endpoints: scope, operations, properties, references, denial code and indirect paths.
  2. Enforce it in the data-access layer. Scoped repositories, ownership predicates in every query, affected-row counts for writes, set-based checks for batches, and the tenant taken from the session.
  3. Back it in the database with row-level security where the platform supports it, and confirm the application does not connect as a role that bypasses it.
  4. Inventory every reference location: path, query, body, nested resources, GraphQL node and field arguments, batch lists, headers, filenames and export jobs.
  5. Build the harness with two tenants, two roles in each, an anonymous client, canary fixtures, an ownership map and expected outcomes derived from the model.
  6. Test writes by side effect. Read the object again as its owner after every cross-identity write or delete.
  7. Run positive controls in every run, so a dead session cannot pass as a column of denials.
  8. Compare another tenant's real object with a nonexistent one, and treat any difference in status, body or timing as an existence oracle.
  9. Fail the build on an unclassified endpoint. An operation without a row in the matrix has not been tested.
  10. Do not count random IDs as the control. They are defence in depth against guessing, not authorisation.

A scanner can tell you an endpoint returned 200. Only something that knows who owns the object can tell you whether it should have.

More about AppSecD · All posts