How-to › Release and secure packages

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.

Audience
API producer
Level
advanced
Topic
Harden clients and servers
Languages
TypeScript and JavaScript
Verified

A customer pastes a meter id into a support ticket and mentions, in passing, that the readings look wrong. The id belongs to a different customer. The parent route checks ownership. The readings route was added six months later, by somebody who read the parent route for style rather than for rules. Nothing in the test suite ever used two tenants at once.

What you get

You will end up with a matrix that tries every identifier against every credential on every route. It reports two kinds of finding: a resource reached by the wrong caller, and a refusal that reveals the resource exists. This is for you if your API serves more than one customer from one database.

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, which is worth reading for how ordinary the code that causes it looks.1 The OWASP testing guide 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

ApproachWhen it fitsWhat it costs youWhen to pick something else
A policy engine such as OPARules complex enough to be worth expressing once, away from the handlersA second language and a deployment, plus a call in every request pathThe rule is always ownership, which a middleware can express
A probe matrix in your test suiteEvery multi-tenant API, as the check that the other approaches workedFixtures for two tenants, and a list of routes to keep currentNothing: this is how you find out, whatever enforces the rule
Per-handler ownership checksA small API where the rule is obvious and the handlers are fewOne forgotten check per new route, which is the usual failureRoutes are added faster than they are reviewed
The sdkgen rbac feature as a client-side hintYou want a generated client to hide operations a caller cannot useNothing enforced, because the client runs on the caller’s machineThe 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.

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.

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.

node demo.mjs | head -7
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.

node demo.mjs | tail -9
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.

node --test probe.test.mjs
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.

Last verified

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

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

Footnotes

  1. It was first in the 2019 edition as API1:2019 and first again in the 2023 edition 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. ↩︎ Back to text

  2. Open Policy Agent writes its rules in Rego, which its documentation says was inspired by Datalog and extended to structured documents such as JSON. The CNCF 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. ↩︎ Back to text

  3. RFC 9110 says an origin server that wishes to hide the existence of a forbidden resource may respond with a 404 instead of a ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.