API tokens
Personal access tokens — the Knox / DRF-token replacement. A non-browser client
(a script, CI job, or integration) sends Authorization: Bearer sk_live_… and is
authenticated as the issuing user. Tokens are verified at the
session choke point, so every gated server function and API route
accepts a token exactly as it accepts a cookie — like adding
TokenAuthentication to DRF's DEFAULT_AUTHENTICATION_CLASSES.
Minting a token
createApiToken returns the raw value exactly once; only its SHA-256 hash is
persisted. This is the real implementation, embedded straight from the source so
these docs can't drift from the code:
/**
* Mint a token for a user. Returns the raw value exactly once; only its hash
* is persisted. `expiresInDays` is optional (null = never expires).
*/
export async function createApiToken(opts: {
userId: string;
name: string;
expiresInDays?: number | null;
scopes?: string[];
}): Promise<CreatedApiToken> {
const raw = PREFIX + randomBytes(TOKEN_BYTES).toString("base64url");
const id = randomBytes(16).toString("hex");
const prefix = raw.slice(0, PREFIX.length + 6); // "sk_live_a1b2c3"
const expiresAt =
opts.expiresInDays == null ? null : new Date(Date.now() + opts.expiresInDays * 86_400_000);
await db.insert(apiToken).values({
id,
userId: opts.userId,
name: opts.name,
tokenHash: hashToken(raw),
prefix,
scopes: opts.scopes ?? [],
expiresAt,
});
return { id, name: opts.name, token: raw, prefix, expiresAt };
}The return type makes the "shown once" contract explicit — token is the only
field that ever carries the raw secret:
async function () {
const = await ({ : "u_1", : "CI deploy", : 90 });
return .; // surface this once; you can never read it again
}Verifying a token
verifyApiToken turns a raw Authorization header into a User, or null. It
applies the same anonymity rule as the cookie path — malformed, unknown,
revoked, expired, and deactivated-owner tokens all resolve to null:
/**
* Resolve a raw `Authorization` header value to a user, or null. Rejects
* malformed, unknown, revoked, expired and deactivated-owner tokens — the
* same anonymity rule as the cookie path. Bumps `lastUsedAt` on success
* (best-effort). The hash lookup is constant-work; the timing-safe compare
* guards the unique-hash equality.
*/
export async function verifyApiToken(authorizationHeader: string | null): Promise<User | null> {
if (!authorizationHeader) return null;
const match = /^Bearer\s+(sk_live_[A-Za-z0-9_-]+)$/u.exec(authorizationHeader.trim());
if (!match) return null;
const raw = match[1]!;
const wantHash = hashToken(raw);
const row = await db.query.apiToken.findFirst({
where: and(eq(apiToken.tokenHash, wantHash), isNull(apiToken.revokedAt)),
});
if (!row) return null;
// Defense in depth against a hash collision / lookup quirk.
const a = Buffer.from(row.tokenHash);
const b = Buffer.from(wantHash);
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
if (row.expiresAt && row.expiresAt.getTime() < Date.now()) return null;
const owner = await db.query.user.findFirst({ where: eq(user.id, row.userId) });
if (!owner || owner.deactivatedAt) return null;
// Best-effort usage stamp; never block the request on it.
void db
.update(apiToken)
.set({ lastUsedAt: new Date() })
.where(eq(apiToken.id, row.id))
.catch(() => {});
return owner;
}A few deliberate choices worth calling out:
- Hash, never the secret. The lookup is by
tokenHash; the raw value never touches the database. - Timing-safe compare guards the unique-hash equality (defense in depth against a lookup quirk).
lastUsedAtis best-effort — the usage stamp is fired and forgotten, never blocking the request.
Listing and revoking
Both are scoped to the owner, and neither ever returns a secret:
// Safe to render — hashes and raw values never leave the server.
const = await ("u_1");
// Scoped to the owner, so a user can't revoke someone else's token.
const = await ("u_1", "tok_abc");| Function | Returns | Notes |
|---|---|---|
createApiToken | CreatedApiToken (raw token, once) | expiresInDays optional; null = never |
verifyApiToken | User | null | called for you at the session choke point |
listApiTokens | metadata only (id, name, prefix, dates) | excludes revoked tokens |
revokeApiToken | boolean | owner-scoped; true when a row was revoked |
Using a token
curl https://app.example.com/api/me \
-H "Authorization: Bearer sk_live_a1b2c3..."No special endpoint or middleware is needed on the server — because tokens are
resolved at the session choke point, requireSession() already
accepts them.