# Access Control Proof

> Add or audit an authorization gate — tenant scoping, role or group checks, share links, admin-only routes — and prove that it denies. Use whenever a new endpoint, resolver, query, page or link exposes data that belongs to someone, and whenever a review asks whether a gate actually works.

- Skill: `chinthakat/access-control-proof` (Agent Skill)
- Install (CLI): `npx skillmds@latest add chinthakat/access-control-proof`
- Raw SKILL.md: https://api.skillmd.com/api/skills/chinthakat/access-control-proof/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- Author: chinthakat (https://skillmd.com/u/chinthakat)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/chinthakat/access-control-proof

---


# 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:

```sql
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

