
# 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.

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

## Issuing

```ts
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

```ts
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.

```ts
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.

```ts
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`](/generate/uuid) 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.

```ts
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`](/generate/secrets/compare) 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](/examples/password-storage).
