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 charactersThe 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
===.secureComparereads 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.