Test and operate APIs

Write an API security matrix

Test every route across identity, role, object relationship, input boundary, rate limit, and expected security event.

Testing an API with one happy-path token tells you the API works. It tells you nothing about who else it works for.

A security matrix fixes that. You take one route and change one security condition at a time: the identity, the role, the object, the input, the repetition. When a cell fails, it points at exactly one broken control.

Choose the rows

For identities, include anonymous, expired, revoked, low-privilege, wrong-tenant, and administrative. For requests, try another user’s object, hidden properties, alternate methods, batch calls, oversized input, and repeated calls.

For one high-value route, the matrix reads like this:

route: POST /invoices/:id/refund
  anonymous              -> 401, no refund row
  expired token          -> 401, no refund row
  wrong tenant           -> 404, no refund row
  low-privilege role     -> 403, no refund row, denial event logged
  admin, valid           -> 200, one refund row, audit event logged
  admin, key replayed    -> 200 same body, still exactly one refund row

Each row is a real request with real credentials against a real database. Not a mocked unit test. Authorization bugs live in the wiring between middleware, handler, and query. A mock skips exactly the layer you’re trying to test.

Assert state, not just status

A 403 alone does not prove safety. The invoice may have changed before the response was written. So each cell checks four things: identity, outcome, stored state, and the expected security event.

Here’s the wrong-tenant cell as a test:

const res = await api.post('/invoices/inv_9f3c2a/refund', body, wrongTenantToken)
assert.equal(res.status, 404)

const refunds = await db.query(
  'SELECT count(*) FROM refunds WHERE invoice_id = $1', ['inv_9f3c2a'])
assert.equal(refunds.rows[0].count, '0')   // no side effect happened

const events = await db.query(
  "SELECT count(*) FROM audit_events WHERE type = 'authz_denied'")
assert.equal(events.rows[0].count, '1')    // the denial left evidence

Three assertions. The response was right, the database didn’t change, and the denial got logged. Drop any one and the test proves less than you think.

Prioritize by harm

Don’t try to automate every permutation on day one. Rank cells by the damage a failure would cause. Cross-tenant writes and money movement need stronger evidence than a malformed optional field.

Forty high-value cells that run on every commit beat four thousand cells nobody maintains. I would rather have the forty.

One more step keeps the matrix honest. Break a policy on purpose. Comment out one permission check locally and confirm the matching cell goes red. A matrix that stays green through a real regression is measuring nothing.

Try this on your most sensitive route: automate the top rows for two users, two tenants, an expired token, and an oversized body. Assert on response, database state, and audit event. Then disable one permission check and prove the matrix catches it.

Lesson completed