Proving an access gate
An access gate is not done when it lets the right person through. It is done when you have watched it turn the wrong person away.
1. Scope from the credential, never from the request
The tenant, org, user or account id comes out of the verified token. It never comes out of a path parameter, query string, body field or header that the caller controls.
GET /api/documents?orgId=7 <- the caller chose the org. This is the bug.
GET /api/documents <- org comes from the token claim. Correct.
Where a resource id is in the path, the ownership check belongs in the query,
not in a separate if after the fetch:
SELECT * FROM documents WHERE id = :id AND org_id = :org_from_token
Two reasons this beats fetch-then-check. It cannot be forgotten on one of the five code paths that load a document. And it returns the same "not found" for "does not exist" and "not yours", which stops the endpoint confirming that someone else's id is real.
Make it structural. One wrapper, helper or base query that every handler goes through, so an unscoped query looks wrong on sight during review.
2. A declared gate can be silently inert
This is the failure worth checking by hand, because everything looks correct:
- A framework directive or decorator that the runtime ignores in the configuration you actually run — for example an auth directive that stops being enforced once more than one auth mode is enabled on the API.
- A middleware registered after the route it was meant to protect.
- A policy attached to a resource that a different, more permissive policy already allows.
- A guard that returns a value instead of throwing, so execution continues.
- A rule in a config file that never loads in the deployed environment.
None of these fail a build. None of them log. The route simply answers.
So: do not conclude a gate works because it is written down. Call the endpoint with the wrong identity and read the response body.
3. The deny-test must use a real, wrong identity
The commonest worthless test is "a request with no credential is rejected". Of course it is — that is the authentication layer, and it was already working. It proves nothing about authorization.
A deny-test needs a credential that is valid and wrong:
| Gate | The credential the test needs |
|---|---|
| Tenant scoping | A real token for tenant B, asking for tenant A's row |
| Role gate | A real token for a signed-in user not in the required role |
| Ownership | A real token for a second user in the same tenant |
| Share link | An expired token, a revoked token, a token for another resource |
Mint these once, keep them as fixtures, and assert on both the status and the body. An empty success response is not a denial — it may mean the filter ran and found nothing, which passes for the wrong reason and will keep passing after someone removes the filter.
For every gate, two tests: one allow, one deny. A gate with only an allow-test is untested.
4. Enumerate the identities before you start
Write the list down — anonymous, each role, each tenant, the second user in a tenant, the expired session, the revoked link — and for each one, what it should see on this endpoint. It takes five minutes and it is what surfaces the case nobody thought about, which is almost always the other user in the same tenant.
5. Public and shared routes are a different surface
A link that works without signing in has no identity to scope by, so everything has to be done at the edges:
- Strip fields. The public view returns a subset, built by listing what goes out. Never serialise the internal object and delete a few keys — the next field someone adds will leak by default.
- Unguessable tokens. Random and long, never derived from the resource id.
- Expiry and revocation, both enforced server-side on every request, and both covered by a test.
- Rate limit by address, because there is no account to limit by.
- Assume the URL will end up in a referrer header, a chat log and a search index.
6. Check the data layer too
Application-level scoping is undone by anything that reads around it: a reporting query, an export job, a search index, an aggregate endpoint, a webhook payload, a cache. Walk the list of everything that reads the table, not just the routes.
Cache keys deserve their own look. A cache keyed on the resource id but not the viewer will serve tenant A's response to tenant B — intermittently, which makes it very hard to believe when it is reported.
Checklist
- Every identifier used for scoping comes from the verified credential
- Ownership is a query condition, not a post-fetch check
- Same response for "not found" and "not yours"
- Gate verified by calling it, not by reading the declaration
- Deny-test per gate, using a valid credential with the wrong identity
- Status and body both asserted; an empty success is not accepted as denial
- Identity matrix written down, including a second user in the same tenant
- Public routes: explicit field allow-list, random token, server-side expiry and revocation, rate limit
- Reports, exports, search indexes and caches scoped too