Issue API tokens

Generate from a CSPRNG, store only the digest, look it up by digest, compare in constant time.

#Why a digest and not Argon2

A password is short and guessable, so it needs a slow hash. A generated token has full random entropy, so there is nothing to guess and a fast SHA-256 is the right cost. That also makes the token usable as a lookup key: you can index the digest column and find the row in one query.

import { hash } from "unsecure/hash";
import { secureGenerate } from "unsecure/generate";
import { secureCompare } from "unsecure/compare";

#Issuing

export async function issueToken(userId: string, label: string) {
  const token = secureGenerate({ length: 48, specials: false });
  // "eO1k3t091l72uZTqhY050kEJ6vdB0j0V…"

  await db.tokens.insert({
    userId,
    label,
    tokenHash: await hash(token),
    createdAt: new Date(),
  });

  return token; // shown once, never stored in the clear
}

Dropping specials keeps the token safe to paste into a URL, a header, a shell or a YAML file without quoting.

48 characters over 62 alphanumerics is around 285 bits. Anything past 128 bits is already far beyond brute force; the extra length is free.

#Checking

export async function authenticate(received: string | null) {
  if (!received) return null;

  const receivedHash = await hash(received);
  const row = await db.tokens.findByHash(receivedHash);
  if (!row) return null;

  // The lookup already matched, so this is belt and braces — but it is the
  // comparison that must never short-circuit
  return secureCompare(row.tokenHash, receivedHash) ? row : null;
}

The digest is what you look up and what you compare. The plaintext token never touches the database.

#A prefix, so a token is identifiable

Tokens leak into logs, screenshots and repositories. A fixed prefix lets a secret scanner recognize one, and lets you route a lookup without hashing.

const PREFIX = "myapp_";

export async function issueToken(userId: string) {
  const token = PREFIX + secureGenerate({ length: 48, specials: false });
  await db.tokens.insert({ userId, tokenHash: await hash(token) });
  return token;
}

export async function authenticate(received: string | null) {
  if (!received?.startsWith(PREFIX)) return null;
  // …
}

Hash the whole string, prefix included. What you hash and what you compare must be the same bytes.

#Sortable tokens

timestamp: true prefixes the output with an encoded timestamp, so tokens sort by when they were issued. The prefix is counted inside length, not added to it.

secureGenerate({ length: 24, timestamp: true }); // "mtpsscichR^y)%?5u[g%&SS-"
secureGenerate({ length: 8, timestamp: true }); // throws: no room for random characters

The timestamp is not a secret and is readable by anyone holding the token. If that matters, use a uuidv7 as the record's id and keep the token opaque.

#Rotation and revocation

Because you store a digest and not the token, revocation is a row delete and rotation is issue-then-delete.

export async function rotateToken(tokenId: string, userId: string) {
  const next = await issueToken(userId, "rotated");
  await db.tokens.delete(tokenId);
  return next;
}

Give tokens an expiry column and check it after the lookup. A token that cannot be revoked in one place is a token you cannot revoke.

#What to get right

  • Show it once. If you can display a token twice, you stored it in the clear.
  • Never log it. Log the digest, or the token's row id.
  • Do not use ===. secureCompare reads the same number of bytes whatever the input.
  • Do not use Argon2 here. A 140 ms hash on every API request is a self-inflicted rate limit.
  • Do use Argon2 for anything a human typed, including recovery codes. See Store passwords.