Files
relata/docs/decisions/0009-api-tokens-hashed-and-scoped.md
2026-08-11 10:40:10 +01:00

81 lines
3.8 KiB
Markdown

# 9. API tokens are hashed, scoped, and bounded by their owner's role
**Status:** accepted
## Context
Programs need to ingest and query without a browser session. Sessions are the wrong shape for
that: they expire on idleness, rotate, and are bound to a cookie and a CSRF token.
## Decision
Bearer tokens a person creates for themselves. Only a SHA-256 hash is stored. Each token
carries explicit scopes (`ingest`, `query`, `review`), and its effective permission is the
intersection of those scopes and its owner's **current** role, re-read on every request.
Tokens authenticate `/api/v1` only, and cannot manage tokens.
## Rationale
### Hash, not encrypt
An encrypted token is recoverable by whoever holds the key, which is the same process that
holds the database connection. Hashing means a database read, a backup, a dump or a stolen
snapshot yields nothing that authenticates. The cost — the secret cannot ever be shown again —
is the point rather than a limitation, and the interface says so at the moment of creation.
Authentication is a lookup on a unique index over the whole hash. There is no comparison in
application code for an attacker to time, and no way to probe for a prefix. The 12-character
display prefix is kept in clear so a person can tell their tokens apart; it is far too short to
be a usable fraction of a 32-byte secret.
### Intersection, not assignment
Scopes could have been a fixed grant checked on its own. Making permission the *intersection*
with the owner's live role means one thing that a fixed grant cannot give: **demoting or
disabling a person immediately narrows or kills every token they hold**, without anyone having
to find those tokens. The user record is re-read per request precisely to make that true; a
role cached on the token would mean the change took effect at expiry, which is exactly when it
does not matter.
### Tokens cannot manage tokens
The single most valuable thing a leaked credential can do is make itself permanent. Requiring
an interactive session for creation, listing and revocation means a leaked token has a fixed
expiry it cannot extend, cannot mint replacements, and cannot revoke the tokens that would
replace it.
### The CSRF exemption is about *how*, not *whether*
Bearer requests carry no CSRF token, and should not have to: CSRF defends against a
third-party page spending *ambient* credentials, and an `Authorization` header is not ambient —
a browser never attaches one by itself.
The dangerous way to implement that is to skip CSRF when an `Authorization` header is present,
because then any cookie-authenticated request can disable the check by adding one. So the
exemption keys off how the request actually authenticated, and authentication resolves the
session cookie **first**. A request holding a cookie is never token-authenticated, so the
header cannot switch anything off. This was verified against a running instance, not only
reasoned about.
### Rejected: PATs that never expire
Convenient, and the reason so many leaked credentials stay live for years. Every token has an
expiry, capped by configuration, and stops working with no further action.
### Rejected: tokens in query parameters
A token in a URL ends up in access logs, browser history, referrer headers and screenshots.
Only the `Authorization` header is read.
## Consequences
**Good:** a stolen database yields no usable credential; a role change takes effect instantly
across every token; a leaked token is bounded in scope, in lifetime and in what it can escalate
to.
**Bad:** a lost token cannot be recovered, only revoked and replaced. Scopes are coarse — three
of them, checked per route, with no per-item restriction. Creating a token through the
interface puts its value in the browser's history for one page load, which the page warns
about; the API route returns it in a response body and avoids that entirely.