Testing Authorization Systematically
Authorization bugs are invisible to scanners, so the only reliable detection is a test matrix you generate.
A scanner can find SQL injection because a payload produces a distinctive response. It cannot find BOLA, because it has no idea that invoice 1044 belongs to someone else. Authorization is the one area where automated security tooling does not help you — and therefore the one that most needs tests you write.
The matrix
Every test is a triple: actor × action × object. Enumerate them and the gaps become obvious.
| Actor | Own object | Other user's | Other tenant's | Admin action |
|---|---|---|---|---|
| Anonymous | 401 | 401 | 401 | 401 |
| User | 200 | 404 | 404 | 403 |
| Read-only user | 200 read / 403 write | 404 | 404 | 403 |
| Other-tenant admin | — | 404 | 200 | 403 here |
| Admin | 200 | 200 (audited) | 404 | 200 |
Generate it, do not hand-write it
Hand-written authorization tests cover the routes someone remembered. Generate the matrix from your route inventory so a route added on Friday is covered on Friday.
Assert the side effect, not only the status
A handler can return 404 after performing the write. Assert that the row is unchanged, not merely that the response looked right.
Fixtures that make it possible
You need at least: two users in one tenant, one user in another tenant, one admin, one read-only account, and one object of each type owned by each. Without a second tenant in your test database, isolation bugs are undetectable.
The other paths
Run the same matrix against exports, search, webhooks and GraphQL. That is where the bugs are, because those paths were added after the REST route was reviewed.
Example
// The fixture set that makes authorization testable at all
export async function seedAuthzFixtures() {
const acme = await createTenant('acme');
const globex = await createTenant('globex');
return {
acme, globex,
alice: await createUser(acme, { role: 'user' }),
bob: await createUser(acme, { role: 'user' }), // same tenant
carol: await createUser(globex, { role: 'user' }), // other tenant
viewer: await createUser(acme, { role: 'viewer' }), // read-only
admin: await createUser(acme, { role: 'admin' }),
gAdmin: await createUser(globex, { role: 'admin' }), // other tenant admin
};
}
// Six accounts across two tenants. Everything else is generated from them.When to use it
- A generated matrix catches a new bulk endpoint that returns other users' records, on the day it is merged.
- A test asserting the row is unchanged catches a handler that deletes before checking ownership and then returns 404.
- Running the same matrix against the CSV export reveals it ignores the tenant filter the REST endpoint applies.
More examples
The generated matrix
The gAdmin row is the one that finds real bugs: an administrator with genuine privileges, in the wrong tenant, is a case hand-written tests almost never cover.
import routes from '../route-inventory.json' with { type: 'json' };
// actor × expectation, per relationship to the object
const MATRIX = [
{ actor: 'anonymous', own: 401, otherUser: 401, otherTenant: 401 },
{ actor: 'alice', own: [200, 204], otherUser: 404, otherTenant: 404 },
{ actor: 'viewer', own: { read: 200, write: 403 }, otherUser: 404, otherTenant: 404 },
{ actor: 'admin', own: [200, 204], otherUser: [200, 204], otherTenant: 404 },
{ actor: 'gAdmin', own: 404, otherUser: 404, otherTenant: [200, 204] },
];
const objectRoutes = routes.filter((r) => r.takesId);
describe('authorization matrix', () => {
let fx, objects;
beforeAll(async () => {
fx = await seedAuthzFixtures();
// One object of each type owned by each of alice / bob / carol
objects = await seedObjectsForEach(fx);
});
for (const route of objectRoutes) {
const resource = route.path.split('/')[2];
const isWrite = route.method !== 'GET';
for (const row of MATRIX) {
for (const [relation, owner] of [
['own', 'alice'], ['otherUser', 'bob'], ['otherTenant', 'carol'],
]) {
it(`${route.method} ${route.path} · ${row.actor} · ${relation}`, async () => {
const id = objects[owner][resource];
if (!id) return;
const before = await db(resource).where({ id }).first();
const req = request(app)[route.method.toLowerCase()](
route.path.replace(/:\w+/, id));
if (row.actor !== 'anonymous') {
req.set('Authorization', `Bearer ${tokenFor(fx[row.actor])}`);
}
const res = await req.send(isWrite ? { name: 'changed' } : undefined);
let expected = row[relation];
if (expected && expected.read !== undefined) {
expected = isWrite ? expected.write : expected.read;
}
expect([].concat(expected)).toContain(res.status);
// The assertion most suites omit: a rejected write must not have
// happened. A 404 returned AFTER the update is still a breach.
if (isWrite && ![200, 204, 201].includes(res.status)) {
const after = await db(resource).where({ id }).first();
expect(after).toEqual(before);
}
});
}
}
}
});Covering the other paths to the same data
Exports top the list because they are usually written once, quickly, by someone reusing a query that predates the authorization model.
// The REST route is reviewed. These are not, and they return the same rows.
describe('secondary data paths respect authorization', () => {
it('CSV export contains only the caller\'s rows', async () => {
const csv = await request(app).get('/api/invoices/export')
.set('Authorization', `Bearer ${aliceToken}`).expect(200);
const ids = parseCsv(csv.text).map((r) => r.id);
const foreign = await db('invoices')
.whereIn('id', ids).whereNot({ user_id: alice.id });
expect(foreign).toHaveLength(0);
});
it('search results are scoped to the tenant', async () => {
await reindexAll();
const res = await request(app).get('/api/search?q=invoice')
.set('Authorization', `Bearer ${aliceToken}`).expect(200);
expect(res.body.hits.every((h) => h.tenantId === acme.id)).toBe(true);
});
it('GraphQL resolvers enforce the same rules as REST', async () => {
const res = await request(app).post('/graphql')
.set('Authorization', `Bearer ${aliceToken}`)
.send({ query: `{ invoice(id: "${bobInvoice.id}") { id total } }` });
expect(res.body.data.invoice).toBeNull();
// And it must not leak existence through the error message either.
expect(JSON.stringify(res.body.errors ?? '')).not.toMatch(/forbidden|denied/i);
});
it('webhook payloads contain only the subscribing tenant\'s data', async () => {
const deliveries = await captureWebhookDeliveries(() => createInvoice(acme));
for (const d of deliveries) {
expect(d.payload.tenantId).toBe(d.subscription.tenantId);
}
});
it('the admin panel uses the same authorization as the API', async () => {
const res = await request(app).get(`/admin/invoices/${globexInvoice.id}`)
.set('Authorization', `Bearer ${acmeAdminToken}`);
expect(res.status).toBe(404); // admin of acme, invoice of globex
});
});
// Ordered by how often each one is actually broken:
// 1. exports 2. search indexes 3. GraphQL 4. webhooks 5. admin panelsContinuous verification against a live environment
Post-deploy smoke tests catch what unit tests structurally cannot: an environment variable, a proxy rule, or a feature flag that differs in the real environment.
#!/usr/bin/env bash
# scripts/authz-smoke.sh — run against staging after every deploy.
# Unit tests can pass while configuration diverges; this checks reality.
set -uo pipefail
BASE="${BASE:-https://staging.dfg.com}"
FAIL=0
check() { # check <expected> <description> <curl args...>
local expected="$1" desc="$2"; shift 2
local code; code=$(curl -s -o /dev/null -w '%{http_code}' "$@")
if [ "$code" != "$expected" ]; then
echo "FAIL [$code, want $expected] $desc"; FAIL=1
else
echo "ok [$code] $desc"
fi
}
# BOLA
check 404 "alice cannot read bob's invoice" \
"$BASE/api/invoices/$BOB_INVOICE" -H "Authorization: Bearer $ALICE"
check 404 "alice cannot delete bob's invoice" \
-X DELETE "$BASE/api/invoices/$BOB_INVOICE" -H "Authorization: Bearer $ALICE"
# Cross-tenant
check 404 "acme cannot read globex's invoice" \
"$BASE/api/invoices/$GLOBEX_INVOICE" -H "Authorization: Bearer $ALICE"
# BFLA
check 403 "normal user cannot reach admin routes" \
"$BASE/api/admin/users" -H "Authorization: Bearer $ALICE"
check 401 "admin routes reject anonymous" "$BASE/api/admin/users"
# Mass assignment — check the EFFECT, not the status
curl -s -X PATCH "$BASE/api/users/me" -H "Authorization: Bearer $ALICE" \
-H 'Content-Type: application/json' -d '{"role":"admin"}' > /dev/null
role=$(curl -s "$BASE/api/users/me" -H "Authorization: Bearer $ALICE" | jq -r .role)
[ "$role" = "admin" ] && { echo "FAIL mass assignment: role became admin"; FAIL=1; } \
|| echo "ok mass assignment rejected"
# Resource limits
n=$(curl -s "$BASE/api/invoices?limit=1000000" -H "Authorization: Bearer $ALICE" | jq 'length')
[ "$n" -gt 100 ] && { echo "FAIL page size not capped ($n)"; FAIL=1; } \
|| echo "ok page size capped at $n"
exit $FAIL
# Wire it into the deploy pipeline as a gate. It takes six seconds and it has
# caught configuration drift that every unit test missed.
Discussion