Skip to content
fusion-auth

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.

Loading diagram...

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:

api-token.ts
/**
 * 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:

api-token.ts
/**
 * 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).
  • lastUsedAt is 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");
FunctionReturnsNotes
createApiTokenCreatedApiToken (raw token, once)expiresInDays optional; null = never
verifyApiTokenUser | nullcalled for you at the session choke point
listApiTokensmetadata only (id, name, prefix, dates)excludes revoked tokens
revokeApiTokenbooleanowner-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.