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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A policy engine such as OPA | 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 | 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 | 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 | 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.
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.
Related how-tos
Last verified
Verified 2026-09-14 against Node 22.22.2. All three output blocks are what the preceding command printed.
- 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
-
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
-
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
-
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