# How to test an API for broken object level authorization

> Try every identifier with every credential and assert that a caller who does not own a resource gets the same answer as one asking for something that does not exist.

Source: https://voxgig.com/howto/test-for-broken-object-level-authorization

- Audience: api-producer
- Level: advanced
- Languages: typescript, javascript
- Verified: 2026-09-14
- Published: 2026-09-14
- Updated: 2026-09-24

## Short answer

Build a matrix: every route, every credential, every identifier, including one that belongs to nobody. A caller reaching a resource they do not own is a finding. A caller refused with a different status from the one a missing identifier gets is a second finding, because the difference tells an attacker the identifier is real.

---
## You will need

Node 22 or later, and an API with resources owned by more than one tenant. The failure is first on
the [OWASP API Security Top
10](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/), which
is worth reading for how ordinary the code that causes it looks.[^1] The [OWASP testing
guide](https://wstg.owasp.org/v4.2/4-Web_Application_Security_Testing/05-Authorization_Testing/04-Testing_for_Insecure_Direct_Object_References/)
describes the same probe by hand.

Voxgig maintains sdkgen. This page compares a probe matrix with per-handler checks, a policy engine,
and the sdkgen `rbac` feature, which is a client-side convenience rather than a control.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A policy engine such as OPA](https://www.openpolicyagent.org/docs/latest/) | Rules complex enough to be worth expressing once, away from the handlers | A second language and a deployment, plus a call in every request path | The rule is always ownership, which a middleware can express |
| [A probe matrix in your test suite](https://nodejs.org/api/test.html) | Every multi-tenant API, as the check that the other approaches worked | Fixtures for two tenants, and a list of routes to keep current | Nothing: this is how you find out, whatever enforces the rule |
| [Per-handler ownership checks](https://expressjs.com/en/guide/writing-middleware.html) | A small API where the rule is obvious and the handlers are few | One forgotten check per new route, which is the usual failure | Routes are added faster than they are reviewed |
| [The sdkgen `rbac` feature as a client-side hint](https://github.com/voxgig/sdkgen) | You want a generated client to hide operations a caller cannot use | Nothing enforced, because the client runs on the caller's machine | The requirement is a control rather than a nicer interface |

The probe is not an alternative to the other three. It is the thing that tells you whether whichever
one you chose is actually applied on every route. A policy engine that a handler forgets to call is
exactly as broken as a missing `if`, and only a test that sends a real request can see the
difference.[^2]

Client-side features deserve a clear line. A generated client that hides operations, the sdkgen
`rbac` feature included, improves the interface for a caller who follows it and stops nobody. The
caller controls the client. Treat it as ergonomics and put the control on the server.

## Include an identifier that belongs to nobody

Three identifiers, and the third is the control.

```js title="demo.mjs"
const rows = await matrix(service.origin, {
  tokens: { tok_acme: 'acme', tok_globex: 'globex' },
  ids: { mtr_a1: 'acme', mtr_g1: 'globex', mtr_zz: 'nobody' },
  routes: [
    { method: 'GET', path: '/meters/{id}' },
    { method: 'GET', path: '/meters/{id}/readings' },
    { method: 'DELETE', path: '/meters/{id}' },
  ],
})
```

Without the third identifier you can tell whether a caller was refused, and not whether the refusal
gave anything away. A route answering 403 for a real resource and 404 for a made-up one is an
enumeration oracle: an attacker learns which identifiers exist by reading status codes.

List the routes explicitly rather than deriving them. A derived list covers what the router knows
about, and the route somebody forgot to register in the test is usually the one they forgot to
check.

## Assert on both kinds of finding

Two functions, because the fixes are different.

```js title="probe.mjs"
export const findings = (rows) =>
  rows.filter((r) => !r.owned && r.status < 400)

/** The other half: a refusal that differs from a miss tells an attacker the id is real. */
export function leaksExistence(rows) {
  const out = []
  for (const route of new Set(rows.map((r) => r.route))) {
    const forRoute = rows.filter((r) => r.route === route && !r.owned)
    const statuses = new Set(forRoute.map((r) => r.status))
    if (statuses.size > 1) out.push(`${route}: unowned ids answer with ${[...statuses].sort().join(' and ')}`)
  }
  return out
}
```

The first is a breach. The second is an information leak, and it is fixed by choosing one status for
both cases, which is usually 404.[^3] Say so in the documentation as well, so a client author knows a
404 may mean either thing and stops treating it as proof of absence.

## Check it worked

Run the matrix against a service with one unchecked route.

```bash
node demo.mjs | head -7
```

```text output
route                          caller  id       owner   status  owned
GET /meters/{id}               acme    mtr_a1   acme    200     true
GET /meters/{id}               acme    mtr_g1   globex  404     false
GET /meters/{id}               acme    mtr_zz   nobody  404     false
GET /meters/{id}               globex  mtr_a1   acme    404     false
GET /meters/{id}               globex  mtr_g1   globex  200     true
GET /meters/{id}               globex  mtr_zz   nobody  404     false
```

That is what a correct route looks like: owners get 200, everybody else gets 404, and a real
identifier belonging to somebody else is indistinguishable from one that was never issued.

```bash
node demo.mjs | tail -9
```

```text output
DELETE /meters/{id}            globex  mtr_g1   globex  200     true
DELETE /meters/{id}            globex  mtr_zz   nobody  404     false

2 findings
  GET /meters/{id}/readings let acme reach mtr_g1, owned by globex, with 200
  GET /meters/{id}/readings let globex reach mtr_a1, owned by acme, with 200

existence leaks
  GET /meters/{id}/readings: unowned ids answer with 200 and 404
```

Both tenants read each other's readings, and the same route also leaks which identifiers are real.
One missing check produced both findings, which is the usual ratio.

The matrix earns its place by being boring on the routes that are correct. Twelve of the eighteen
rows say nothing interesting, and that is the evidence that the two findings are real rather than an
artifact of the fixtures.

```bash
node --test probe.test.mjs
```

```text output
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 372.600123
```

## When it goes wrong

The matrix passes and a breach happens anyway. The probe covers the routes you listed. Generate the
list from the router and fail when a registered route is not in the matrix.

Every route returns 404 including for owners. The fixtures give both tenants the same token, or the
seed data was not created. Assert that owners get 200 before trusting any refusal.

The suite is slow. The matrix is the product of three lists and it grows quickly. Keep the
identifier set small, and run the full product nightly with a smaller set on each pull request.

A write route passes but still lets one tenant affect another. Ownership was checked on the object
in the path and not on an object named in the body. Probe the body parameters too, with an
identifier the caller does not own in each one.

## When not to do this

Do not run the probe against production data. It is designed to reach things it should not, and a
successful finding in production is an incident you created.

Do not rely on identifiers being unguessable instead of checking ownership. Long random ids raise
the cost of enumeration and do nothing about an identifier that was shared, logged, or pasted into a
ticket. Unguessable identifiers and an ownership check are two different controls, and only one of
them survives disclosure.

Do not use the sdkgen `rbac` feature, or any client-side scoping, as the reason a route needs no
check. The generated client is a convenience for callers who follow it, and the route has to survive
the ones who do not.

## Related how-tos

- [Diagnose a 401 or a 403 from a credential](/howto/diagnose-401-and-403-from-a-credential)

- [Rate limit by API key and tenant, not client IP](/howto/key-rate-limits-by-api-key-and-tenant)

## Last verified

Verified 2026-09-14 against Node 22.22.2. All three output blocks are what the preceding command
printed.

[^1]: It was first in the
[2019 edition](https://api-security.owasp.org/editions/2019/en/0xa1-broken-object-level-authorization/)
as API1:2019 and first again in the
[2023 edition](https://api-security.owasp.org/editions/2023/en/0xa1-broken-object-level-authorization/)
as API1:2023, under the same name. Only one other entry, Broken Function Level Authorization at
fifth, kept both its name and its place between the two lists. The 2023 page rates it easy to
exploit, widespread, and easy to detect. It explains that the issue is extremely common because
the server relies on object ids sent from the client to decide which objects to access. Four
years and a new edition changed nothing about its rank.

[^2]: Open Policy Agent writes its rules in Rego, which its
[documentation](https://www.openpolicyagent.org/docs/policy-language) says was inspired by
Datalog and extended to structured documents such as JSON. The
[CNCF](https://www.cncf.io/projects/open-policy-agent-opa/) accepted the project on March 29,
2018, moved it to incubating on April 2, 2019, and graduated it on January 29, 2021, a sequence
of two years and ten months. A graduated project is one the foundation considers stable, widely
adopted and production ready. A handler still has to call it.

[^3]: [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110#section-15.5.4) says an origin server
that wishes to hide the existence of a forbidden resource may respond with a 404 instead of a
403. Its definition of 404, one section on, covers a server that is not willing to disclose that
a representation exists. The standard provides for the concealment and documents it in both
places. That leaves the client with a status that means either of two things, which is why the
documentation has to say so.