Issue API tokens
Give each client a random token to send as a bearer header, store only the token's SHA-256 digest, and find the right row from that digest.
A personal access token is a long random string that a script sends with every request, as Authorization: Bearer acme_pat_…. You don't keep the token: you keep its SHA-256 digest, a fingerprint of it, and look requests up by that. Someone who copies your database gets fingerprints, and a fingerprint can't be sent as a token. Because the token is random rather than chosen by a person, a fast hash is enough; Concepts explains why.
#Before you start
- An
api_tokenstable withuser_id,label,expires_atand an indexeddigestcolumn. - Show the token to its owner once, right after you issue it. If you can show it twice, it is stored in the clear somewhere.
- A token is a bearer credential: whoever holds it acts as its owner until it expires or you delete its row. Serve the API over HTTPS only, and never log the
Authorizationheader.
#Issue and check a token
issueToken runs when someone creates a token on your settings page, and authenticate runs on every API request:
import { secureCompare } from "unsecure/compare";
import { secureGenerate } from "unsecure/generate";
import { hash } from "unsecure/hash";
// A fixed prefix lets secret scanners recognize a leaked token
const PREFIX = "acme_pat_";
const NINETY_DAYS_MS = 90 * 86_400_000;
export async function issueToken(
store: TokenStore,
userId: string,
label: string,
): Promise<string> {
const token = PREFIX + secureGenerate({ length: 32, specials: false });
const expiresAt = new Date(Date.now() + NINETY_DAYS_MS);
await store.insertToken({ userId, label, digest: await hash(token), expiresAt });
return token; // Show it once: only the digest is stored
}
export async function authenticate(
store: TokenStore,
authorization: string | null,
): Promise<TokenRow | null> {
// The scheme name is case-insensitive (RFC 9110 §11.1): "Bearer", "bearer", …
const token = /^bearer +(\S+)$/i.exec(authorization ?? "")?.[1] ?? "";
if (!token.startsWith(PREFIX)) return null;
const digest = await hash(token);
const row = await store.findByDigest(digest);
if (!row || !secureCompare(row.digest, digest)) return null;
return row.expiresAt.getTime() > Date.now() ? row : null;
}Every line on the checking side either rejects a request early or narrows it to one row:
- Length and alphabet: 32 random letters and digits give about 190 bits, far past anything guessable. With
specials: false, the token pastes into a header, a URL, a shell or a YAML file without quoting. - The prefix:
acme_pat_isn't secret, it's a name tag. Secret scanners use it to spot a token pasted somewhere public.authenticateuses it to turn away strings that were never yours, before hashing them. Hash the whole string, prefix included, both when you issue and when you check. - Scheme name: the word before the token is case-insensitive (RFC 9110 §11.1), so
bearerandBEARERwork too. - Lookup by digest: an ordinary indexed lookup is fine here. Its timing can only hint at how much of a digest matched, and nobody can work back from a digest to a token.
secureCompareafter the lookup: the final yes or no happens in your code. If the query breaks one day, say it loses itsWHEREclause, it still can't let a different token in.
#When it fails
Every row below comes back as null, and your route answers each with the same 401:
| Situation | What happens |
|---|---|
No Authorization header, or a scheme other than Bearer | Refused before any hashing. |
Token without the acme_pat_ prefix | Refused before hashing or lookup. |
| Unknown, mistyped or revoked token | The digest matches no row. |
| Expired token | The row exists, but expiresAt is in the past. Delete expired rows on a schedule. |
| Store returns a row with a different digest | secureCompare refuses it. |
#Going further
Ways to build on the same table:
- Revoking and rotating: delete the row and the token stops working on the next request. To rotate, issue a new token, let the client switch over, then delete the old row.
- Scopes: to limit what a token may do, add a
scopescolumn and check it in the route afterauthenticatereturns the row. - Sortable tokens:
secureGenerate({ length: 32, specials: false, timestamp: true })starts the token with the issue time in base 36. The timestamp counts towardlength, and anyone holding the token can read it. See Generate tokens and secrets. - Secrets between your own services: when you own both ends, sign each request with HMAC instead. That way the secret never travels with the request at all.