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_tokens table with user_id, label, expires_at and an indexed digest column.
  • 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 Authorization header.

#Issue and check a token

issueToken runs when someone creates a token on your settings page, and authenticate runs on every API request:

api-tokens.ts
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. authenticate uses 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 bearer and BEARER work 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.
  • secureCompare after the lookup: the final yes or no happens in your code. If the query breaks one day, say it loses its WHERE clause, 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:

SituationWhat happens
No Authorization header, or a scheme other than BearerRefused before any hashing.
Token without the acme_pat_ prefixRefused before hashing or lookup.
Unknown, mistyped or revoked tokenThe digest matches no row.
Expired tokenThe row exists, but expiresAt is in the past. Delete expired rows on a schedule.
Store returns a row with a different digestsecureCompare 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 scopes column and check it in the route after authenticate returns the row.
  • Sortable tokens: secureGenerate({ length: 32, specials: false, timestamp: true }) starts the token with the issue time in base 36. The timestamp counts toward length, 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.