API keys for machine callers
How API keys let non-browser callers authenticate to your app, resolving into the same identity context your route wrapper already produces for session cookies.
When you built authedRoute, you named a gap and pointed ahead to it. The wrapper resolved one kind of caller, the session cookie, and the list of seams it deliberately left alone ended on this one: the cookie is the default identity path, but Authorization: Bearer <token> is a different identity model, for machine-to-machine callers that carry no cookie. That was the one branch the wrapper left for later. This lesson is that later, where you build it.
The gap is real, not theoretical, because a cookie is a browser’s credential. Your domain sets it, the browser replays it automatically on every same-site request, and it lives and dies with a browser session. Now picture the callers you met when you first learned about route handlers: a partner’s job server posting a batch of records to your API at 3am, a command-line tool an engineer runs against your endpoints, the mobile app that someday talks to the same backend. None of them is a browser, so none of them has a cookie. The cookie path, which requireOrgUser leans on entirely, never fires for them. They hit your route.ts carrying nothing your wrapper knows how to read.
So a non-browser caller needs some other way to prove who it is. There’s a sharper version of the question hiding underneath: how do you hand a partner a credential you can take back later, killing it the day the contract ends or the day it leaks, without resetting anyone’s password or disturbing a single human login? That second requirement shapes everything that follows. The answer to both is an API key: a credential your app mints, hands to a machine, and can revoke at will.
The payoff is the same one you should hold onto from the first paragraph: you are not building a second authentication system. You already built the door, authedRoute, with its four gates and its ctx. This lesson adds a second door on the same wall. A request that arrives with Authorization: Bearer <key> gets resolved by the wrapper into the identical ctx = { user, orgId, role, db } a cookie produces, so the role check, the tenant scoping, the audit write, and every handler body all keep working unchanged. Two doors, one room, one more time.
A credential a machine can carry, and you can revoke
Section titled “A credential a machine can carry, and you can revoke”Before any code, get precise about what an API key is, because the shape of the table and the verify logic both fall out of it.
An API key is an opaque, high-entropy secret your app generates and hands to a machine caller. The caller stores it and sends it on every request, and your app verifies it on the way in. The word “opaque” matters: unlike the signed JWTs you weighed against sessions earlier in the course, the key carries no readable claims inside it. It’s just a long random string that points at a row in your database, where the real facts about the caller live. The string is a lookup handle, nothing more.
The clearest way to understand a key is to set it against the three credentials it has to beat.
Against the session cookie. A cookie is browser-bound and auto-replayed, and the browser decides when to send it. A key is sent explicitly, by code the caller wrote, in a header the caller controls. That’s exactly what a non-browser needs: a credential it can attach deliberately to a fetch from a server, where no browser is around to manage a cookie jar.
Against a password. A password is human-chosen, memorable, and singular: one human, one login. Mailing a partner a shared password so their server can call your API would be reckless. You’d be handing out the keys to a human account, and the only way to revoke it is to change the password, which breaks every other thing that used it. A key is machine-generated, not memorable, and revocable on its own, without touching anyone’s login. You can mint one key per partner, scope each one tightly, and kill any single one the moment it leaks, while every other key and every human login keeps working.
Against OAuth client-credentials. There’s a heavier, standards-based model for this: OAuth 2.1’s client-credentials flow, which you met when the course covered the auth mental model. It’s the right reach when you’re building a marketplace of third-party apps that act on users’ behalf. For handing your own known partners a credential in year one, it’s far more machinery than the problem needs. The stored-hash API key is the year-one default, so we name OAuth here once and do not build it.
The point to take from all three is that a key is a privileged grant you can take back. Revocability isn’t a nice-to-have bolted on later. It’s the reason the key exists in this shape instead of as a shared password. That single requirement is why the table you’re about to design carries a revokedAt column, and why minting and killing a key both get recorded in the audit log exactly like a role change. A grant you can take back is a grant you have to track.
One word is about to do a lot of work, so pin it down. A key is a bearer token : whoever bears it is treated as the caller, no questions asked. That’s powerful and unforgiving in equal measure. It’s powerful because verification is trivial: hold the secret, and you’re in. It’s unforgiving because there’s nothing behind the secret, so a leaked key is a working key until you revoke it. Everything in this lesson, from hashing it at rest, to never logging it, to comparing it in constant time, scoping it tightly, and making it revocable, exists because possession alone is the whole game.
The shape: a hashed key, shown once
Section titled “The shape: a hashed key, shown once”Here’s the one genuinely new idea in the lesson, and it’s worth slowing down for: you store the hash, and you show the secret exactly once.
Walk the key’s two halves first. A full key your partner pastes into their config looks like rsk_live_ab12cd34.s3cr3tPartH3re…, and the dot splits it into two parts that do completely different jobs.
- The prefix,
rsk_live_ab12cd34, is the public, visible key-id. It’s safe to show in a settings list (“Acme nightly sync ·rsk_live_ab12…”), safe to store in your database in plaintext, and safe to put in a log line. Its job is to be the lookup handle: when a request comes in, you find the right row by its prefix, without the secret being involved at all. (Thelivesegment is a convention worth borrowing. Arsk_test_prefix for sandbox keys lets a reader tell a production credential from a test one at a glance.) - The secret half, everything after the dot, is the part that actually proves identity. It’s 32 bytes of cryptographically random data from CSPRNG , rendered as a URL-safe text string. You generated random bytes exactly like this earlier in the course, when you learned the browser’s crypto APIs:
crypto.getRandomValuesfills a byte array, and you encode it as base64url so it’s safe to ship as a string.
Now the move that makes a leak survivable. Your database stores the prefix in plaintext and only the SHA-256 hash of the secret half, never the secret itself. The raw full key is assembled once, at creation, returned to the person who minted it that one time, and then it is gone from your systems forever. You never store it, never log it, and never email it to yourself. If your api_keys table leaked in its entirety tomorrow, an attacker would get a list of prefixes and a list of hashes, and could reconstruct exactly zero working keys, because a SHA-256 hash can’t be run backwards into the secret that produced it. That is the entire at-rest security posture in one sentence: a read of the table can never hand out a credential.
There’s a deliberate cryptographic choice buried in “SHA-256,” worth making explicit because the instinct cuts the wrong way. You’ll meet this again very soon, since the next chapter reuses this exact store-the-hash, show-once posture for invitation tokens, and the reasoning is the same in both places. A human password gets a slow hash (bcrypt, argon2) on purpose: passwords are low-entropy and guessable, so you make each guess deliberately expensive. An API key is the opposite. Its 32 bytes of CSPRNG entropy are unguessable by brute force in any timeframe that matters, so a slow hash buys you nothing and just taxes every single verify. The right tool for a high-entropy throwaway secret is a fast cryptographic hash, SHA-256 via crypto.subtle.digest. The fast-versus-slow question is settled the same way wherever it comes up: slow for what a human chose, fast for what your CSPRNG generated.
Before the table, fix the picture of where the raw secret is allowed to exist, because it’s a short list and it’s the whole game. The following figure traces the secret across the three places data could leak from.
prefixkeyHash A full table dump reconstructs no working key.
prefix The prefix identifies the key; the secret never appears.
prefix.secret The single moment the raw secret is allowed to exist.
Now the table. Read it once top to bottom, then walk the columns that carry a decision. Several of them encode a choice you’ve made before in this chapter, and the point is to see the same reasoning land again.
export const apiKeys = pgTable( 'api_keys', { id: uuid().primaryKey().$defaultFn(() => uuidv7()), prefix: text().notNull(), keyHash: text().notNull(), organizationId: uuid() .notNull() .references(() => organization.id, { onDelete: 'cascade' }), createdByUserId: uuid().references(() => user.id, { onDelete: 'set null' }), name: text().notNull(), scopes: text().array().notNull().default([]), lastUsedAt: timestamp({ withTimezone: true }), revokedAt: timestamp({ withTimezone: true }), createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), }, (t) => [ uniqueIndex('api_keys_prefix_unique').on(t.prefix), index('idx_api_keys_org_created').on(t.organizationId, t.createdAt.desc()), ],);The two halves, split into two columns. prefix is the public lookup handle, stored in plaintext; keyHash is the SHA-256 of the secret, never the raw key. The uniqueIndex on prefix is how you find the row on every verify, so it’s both unique and indexed. You look up by the safe half and verify against the hashed half.
export const apiKeys = pgTable( 'api_keys', { id: uuid().primaryKey().$defaultFn(() => uuidv7()), prefix: text().notNull(), keyHash: text().notNull(), organizationId: uuid() .notNull() .references(() => organization.id, { onDelete: 'cascade' }), createdByUserId: uuid().references(() => user.id, { onDelete: 'set null' }), name: text().notNull(), scopes: text().array().notNull().default([]), lastUsedAt: timestamp({ withTimezone: true }), revokedAt: timestamp({ withTimezone: true }), createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), }, (t) => [ uniqueIndex('api_keys_prefix_unique').on(t.prefix), index('idx_api_keys_org_created').on(t.organizationId, t.createdAt.desc()), ],);Two foreign keys, two different onDelete choices, and the difference is the lesson. organizationId cascades, because the org owns the key, so deleting the org takes its keys with it. createdByUserId is set null, mirroring the audit table’s actor column: if the human who minted the key is later deleted, the key’s row and its trail survive with a null creator. An owned resource cascades; an actor reference survives.
export const apiKeys = pgTable( 'api_keys', { id: uuid().primaryKey().$defaultFn(() => uuidv7()), prefix: text().notNull(), keyHash: text().notNull(), organizationId: uuid() .notNull() .references(() => organization.id, { onDelete: 'cascade' }), createdByUserId: uuid().references(() => user.id, { onDelete: 'set null' }), name: text().notNull(), scopes: text().array().notNull().default([]), lastUsedAt: timestamp({ withTimezone: true }), revokedAt: timestamp({ withTimezone: true }), createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), }, (t) => [ uniqueIndex('api_keys_prefix_unique').on(t.prefix), index('idx_api_keys_org_created').on(t.organizationId, t.createdAt.desc()), ],);scopes, a text array, is the capability ceiling. It’s the list of things the key is allowed to do (invoices:read, invoices:write), capping it below the org role it acts under. Its own section is next. It defaults to an empty array, so a key with no scopes can do nothing until you grant it something.
export const apiKeys = pgTable( 'api_keys', { id: uuid().primaryKey().$defaultFn(() => uuidv7()), prefix: text().notNull(), keyHash: text().notNull(), organizationId: uuid() .notNull() .references(() => organization.id, { onDelete: 'cascade' }), createdByUserId: uuid().references(() => user.id, { onDelete: 'set null' }), name: text().notNull(), scopes: text().array().notNull().default([]), lastUsedAt: timestamp({ withTimezone: true }), revokedAt: timestamp({ withTimezone: true }), createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), }, (t) => [ uniqueIndex('api_keys_prefix_unique').on(t.prefix), index('idx_api_keys_org_created').on(t.organizationId, t.createdAt.desc()), ],);lastUsedAt and revokedAt are both nullable, both state, and neither is a delete. lastUsedAt is bumped on every successful verify, so you can answer “is this key still in use, safe to rotate?”. When you kill a key, revokedAt is set rather than the row deleted, so the row stays and lastUsedAt and the audit trail remain legible. Revoking is a state change; you never DELETE the row.
A couple of these deserve a sentence more than a step caption.
As with every table in this codebase, the snake-case mapping lives on the db client, so TypeScript reads organizationId while the SQL column is organization_id. Likewise keyHash is key_hash, lastUsedAt is last_used_at, and so on for every column above. You established that mapping when you built the audit table, and it holds here unchanged.
One thing this table deliberately does not have is a row-level security policy. You wired RLS onto audit_logs because it’s an append-only, quarantined tier where the database itself must forbid mutation. api_keys is ordinary org-owned application data, so it gets the same treatment every other tenant table gets, scoped through tenantDb(orgId) in the application layer. There’s no pgPolicy block here, and you shouldn’t reach for one. RLS is the audit-log tier’s tool; tenantDb is the default for everything else.
Minting and revoking: admin-gated and audited
Section titled “Minting and revoking: admin-gated and audited”Now issue and kill a key. The reassuring part of this section is that there’s no new machinery. Minting a key is a privileged write, so it goes through authedAction. It’s an audit-worthy event, so it ends with logAudit inside the transaction. You built both of those earlier in this chapter, and here they just apply.
Start with creation. Only an admin should be able to mint a key for the org, so the role floor is 'admin', passed as the wrapper’s first argument, the same way every privileged action in this chapter declares its floor.
export const createApiKey = authedAction( 'admin', createApiKeySchema, async (input, ctx) => { const prefix = `rsk_live_${randomToken(8)}`; const secret = randomToken(32); const keyHash = await sha256Hex(secret);
const row = await withTenant(ctx.orgId, async (tx) => { const [inserted] = await tx .insert(apiKeys) .values({ prefix, keyHash, organizationId: ctx.orgId, createdByUserId: ctx.user.id, name: input.name, scopes: input.scopes, }) .returning();
await logAudit(tx, { action: 'api-key.created', subjectType: 'api-key', subjectId: inserted.id, payload: { name: input.name, scopes: input.scopes }, });
return inserted; });
revalidatePath('/settings/api-keys'); return ok({ fullKey: `${prefix}.${secret}`, ...row }); },);Generate the two halves, then hash the secret. randomToken is the CSPRNG-to-base64url pipeline from earlier in the course, and sha256Hex is the fast crypto.subtle.digest('SHA-256', …) rendered to hex. The prefix is public; the secret is hashed, and its plaintext lives only in local variables for the rest of this function.
export const createApiKey = authedAction( 'admin', createApiKeySchema, async (input, ctx) => { const prefix = `rsk_live_${randomToken(8)}`; const secret = randomToken(32); const keyHash = await sha256Hex(secret);
const row = await withTenant(ctx.orgId, async (tx) => { const [inserted] = await tx .insert(apiKeys) .values({ prefix, keyHash, organizationId: ctx.orgId, createdByUserId: ctx.user.id, name: input.name, scopes: input.scopes, }) .returning();
await logAudit(tx, { action: 'api-key.created', subjectType: 'api-key', subjectId: inserted.id, payload: { name: input.name, scopes: input.scopes }, });
return inserted; });
revalidatePath('/settings/api-keys'); return ok({ fullKey: `${prefix}.${secret}`, ...row }); },);One transaction holds the insert and the audit row together. withTenant(ctx.orgId, …) opens the transaction and the tenant scope. The key is inserted, then logAudit(tx, …) records api-key.created on the same tx, so minting a privileged grant is audited exactly like a role change, and the row exists if and only if the key was actually created. That’s the same if-and-only-if guarantee from the audit lesson, reused without a single new idea.
export const createApiKey = authedAction( 'admin', createApiKeySchema, async (input, ctx) => { const prefix = `rsk_live_${randomToken(8)}`; const secret = randomToken(32); const keyHash = await sha256Hex(secret);
const row = await withTenant(ctx.orgId, async (tx) => { const [inserted] = await tx .insert(apiKeys) .values({ prefix, keyHash, organizationId: ctx.orgId, createdByUserId: ctx.user.id, name: input.name, scopes: input.scopes, }) .returning();
await logAudit(tx, { action: 'api-key.created', subjectType: 'api-key', subjectId: inserted.id, payload: { name: input.name, scopes: input.scopes }, });
return inserted; });
revalidatePath('/settings/api-keys'); return ok({ fullKey: `${prefix}.${secret}`, ...row }); },);The show-once return. ok({ fullKey, ...row }) is the only time the assembled prefix.secret leaves the server. The UI displays it once for the partner to copy, and it’s never persisted or recoverable after that. Every later read of this key returns the row without fullKey, because the prefix is all the list needs.
The audit event name and payload are not free choices. The api-key.created event carrying { name, scopes } is a contract the security chapter later in the course relies on when it catalogs every audit event. Match it exactly, because a drift here is a drift the catalog has to chase down.
Revoking is shorter, and it makes the “state change, not a delete” reflex concrete:
export const revokeApiKey = authedAction( 'admin', revokeApiKeySchema, async (input, ctx) => { await withTenant(ctx.orgId, async (tx) => { await tx .update(apiKeys) .set({ revokedAt: new Date() }) .where(eq(apiKeys.id, input.id));
await logAudit(tx, { action: 'api-key.revoked', subjectType: 'api-key', subjectId: input.id, }); });
revalidatePath('/settings/api-keys'); return ok(null); },);Notice what revoke does not do: it never deletes the row. It sets revokedAt to now and leaves everything else in place. The instinct to “clean up” by deleting the row is the trap. Delete it and you lose lastUsedAt, so you can never answer “was this leaked key still being used when we killed it?”, and you lose the row the audit trail points at. Revoke is a state change. The key stops working the instant revokedAt is set, because the verify path you build next refuses any row that carries one, but the record of the key persists.
One detail in that body is easy to misread: the single api_keys UPDATE is wrapped in withTenant even though api_keys is an ordinary tenantDb table with no RLS policy of its own, so it would happily take the update on the bare client. The withTenant is there for its co-transacted passenger. The logAudit insert lands on audit_logs, which is RLS-guarded, so it needs app.org_id set, and it needs to share the update’s transaction so the two commit or roll back as one. The scope is for the audit row, not the api_keys write.
The two schemas are small and follow the chapter’s conventions: top-level z.uuid() and an explicit scope list.
export const createApiKeySchema = z.object({ name: z.string().min(1), scopes: z.array(z.enum(['invoices:read', 'invoices:write'])),});
export const revokeApiKeySchema = z.object({ id: z.uuid(),});Modeling scopes as a z.enum of known strings rather than free-form text is the same instinct as keeping the Role union small: a typo’d scope should fail to parse rather than silently grant or deny something nobody named.
The second identity branch in authedRoute
Section titled “The second identity branch in authedRoute”This is the core of the lesson, and it’s the smallest change you’ll make all day. The wrapper from earlier in this chapter resolves identity exactly one way: it calls requireOrgUser({ headers: request.headers }), which reads the session cookie. You’re going to add one branch ahead of it. If the request carries an Authorization: Bearer header, you resolve identity from the key instead, and produce the identical ctx.
Lead with the picture, because it’s the whole lesson in one image and the one this chapter has been promising. The following figure shows both doors converging.
route.ts Authorization: Bearer?
resolveApiKey — Bearer key
- split prefix.secret
- look up row by prefix
- reject if missing / revoked
- constant-time hash compare
- bump lastUsedAt
ctx = { user, orgId, role, db } roleAtLeast parse call fn That convergence is the entire design. The two branches resolve identity differently, one splitting and hashing a key, the other reading a cookie, but they exit into the same ctx box. From there the wrapper runs the same roleAtLeast gate, the same parse, and calls the same fn. The handler body cannot tell which door you walked through, which means every route handler you already wrote works for key callers without a single change. You don’t touch the handlers. You add one branch to the wrapper, and the whole surface lights up for machines.
Now the new logic: the resolver. This is genuinely the only new algorithm in the lesson, so walk it step by step. Call it resolveApiKey(request). It returns the resolved identity, or null if there’s no Bearer header, which tells the wrapper to fall through to the cookie path. One step inside it leans on a security primitive you’ve seen before, a constant-time compare , and the rest is plain control flow.
import 'server-only';
import { eq } from 'drizzle-orm';
import { db } from '@/db';import { apiKeys } from '@/db/schema';import { sha256Hex, timingSafeEqualHex } from '@/lib/crypto';
export type ResolvedKey = { orgId: string; createdByUserId: string | null; scopes: string[];};
export const resolveApiKey = async ( request: Request,): Promise<ResolvedKey | null> => { const header = request.headers.get('authorization'); if (!header?.startsWith('Bearer ')) return null;
const presented = header.slice('Bearer '.length); const [prefix, secret] = presented.split('.'); if (!prefix || !secret) throw new ApiKeyError('malformed key');
const [row] = await db .select() .from(apiKeys) .where(eq(apiKeys.prefix, prefix)); if (!row || row.revokedAt) throw new ApiKeyError('invalid key');
// constant-time compare to prevent timing attack const presentedHash = await sha256Hex(secret); if (!timingSafeEqualHex(presentedHash, row.keyHash)) { throw new ApiKeyError('invalid key'); }
await db .update(apiKeys) .set({ lastUsedAt: new Date() }) .where(eq(apiKeys.id, row.id));
return { orgId: row.organizationId, createdByUserId: row.createdByUserId, scopes: row.scopes, };};No Bearer header means return null. This branch only fires when an Authorization: Bearer is actually present. A null return is the signal “this isn’t a key request,” which the wrapper reads before falling through to the cookie path. This is how the second door stays additive: with no Bearer header, nothing about the existing flow changes.
import 'server-only';
import { eq } from 'drizzle-orm';
import { db } from '@/db';import { apiKeys } from '@/db/schema';import { sha256Hex, timingSafeEqualHex } from '@/lib/crypto';
export type ResolvedKey = { orgId: string; createdByUserId: string | null; scopes: string[];};
export const resolveApiKey = async ( request: Request,): Promise<ResolvedKey | null> => { const header = request.headers.get('authorization'); if (!header?.startsWith('Bearer ')) return null;
const presented = header.slice('Bearer '.length); const [prefix, secret] = presented.split('.'); if (!prefix || !secret) throw new ApiKeyError('malformed key');
const [row] = await db .select() .from(apiKeys) .where(eq(apiKeys.prefix, prefix)); if (!row || row.revokedAt) throw new ApiKeyError('invalid key');
// constant-time compare to prevent timing attack const presentedHash = await sha256Hex(secret); if (!timingSafeEqualHex(presentedHash, row.keyHash)) { throw new ApiKeyError('invalid key'); }
await db .update(apiKeys) .set({ lastUsedAt: new Date() }) .where(eq(apiKeys.id, row.id));
return { orgId: row.organizationId, createdByUserId: row.createdByUserId, scopes: row.scopes, };};Reject a malformed shape before touching the database. Split prefix.secret on the dot; if either half is missing, the value isn’t even key-shaped, so reject it immediately. Never spend a database round-trip on a string that couldn’t possibly be a key. The cheap structural check comes first, exactly the cheapest-disqualifier-first reflex from the authedRoute lesson.
import 'server-only';
import { eq } from 'drizzle-orm';
import { db } from '@/db';import { apiKeys } from '@/db/schema';import { sha256Hex, timingSafeEqualHex } from '@/lib/crypto';
export type ResolvedKey = { orgId: string; createdByUserId: string | null; scopes: string[];};
export const resolveApiKey = async ( request: Request,): Promise<ResolvedKey | null> => { const header = request.headers.get('authorization'); if (!header?.startsWith('Bearer ')) return null;
const presented = header.slice('Bearer '.length); const [prefix, secret] = presented.split('.'); if (!prefix || !secret) throw new ApiKeyError('malformed key');
const [row] = await db .select() .from(apiKeys) .where(eq(apiKeys.prefix, prefix)); if (!row || row.revokedAt) throw new ApiKeyError('invalid key');
// constant-time compare to prevent timing attack const presentedHash = await sha256Hex(secret); if (!timingSafeEqualHex(presentedHash, row.keyHash)) { throw new ApiKeyError('invalid key'); }
await db .update(apiKeys) .set({ lastUsedAt: new Date() }) .where(eq(apiKeys.id, row.id));
return { orgId: row.organizationId, createdByUserId: row.createdByUserId, scopes: row.scopes, };};Look the row up by prefix, the one deliberate non-tenantDb read in the whole app. Every other query in this chapter goes through tenantDb(orgId), but here there is no orgId yet, because you’re establishing it from the key. You can’t scope by a tenant you haven’t identified, so this one lookup uses the bare db, by design. Reject if there’s no row, or if revokedAt is set, since a revoked key is not a valid credential.
import 'server-only';
import { eq } from 'drizzle-orm';
import { db } from '@/db';import { apiKeys } from '@/db/schema';import { sha256Hex, timingSafeEqualHex } from '@/lib/crypto';
export type ResolvedKey = { orgId: string; createdByUserId: string | null; scopes: string[];};
export const resolveApiKey = async ( request: Request,): Promise<ResolvedKey | null> => { const header = request.headers.get('authorization'); if (!header?.startsWith('Bearer ')) return null;
const presented = header.slice('Bearer '.length); const [prefix, secret] = presented.split('.'); if (!prefix || !secret) throw new ApiKeyError('malformed key');
const [row] = await db .select() .from(apiKeys) .where(eq(apiKeys.prefix, prefix)); if (!row || row.revokedAt) throw new ApiKeyError('invalid key');
// constant-time compare to prevent timing attack const presentedHash = await sha256Hex(secret); if (!timingSafeEqualHex(presentedHash, row.keyHash)) { throw new ApiKeyError('invalid key'); }
await db .update(apiKeys) .set({ lastUsedAt: new Date() }) .where(eq(apiKeys.id, row.id));
return { orgId: row.organizationId, createdByUserId: row.createdByUserId, scopes: row.scopes, };};Constant-time compare, never ===. Hash the presented secret and compare it against the stored hash with timingSafeEqualHex. A naive === short-circuits on the first differing byte, and that tiny timing difference leaks how much of the secret an attacker guessed right, letting them recover it byte by byte. The constant-time compare always examines every byte, regardless of where the values diverge. The // constant-time compare to prevent timing attack comment is the one place a security note earns an inline comment.
import 'server-only';
import { eq } from 'drizzle-orm';
import { db } from '@/db';import { apiKeys } from '@/db/schema';import { sha256Hex, timingSafeEqualHex } from '@/lib/crypto';
export type ResolvedKey = { orgId: string; createdByUserId: string | null; scopes: string[];};
export const resolveApiKey = async ( request: Request,): Promise<ResolvedKey | null> => { const header = request.headers.get('authorization'); if (!header?.startsWith('Bearer ')) return null;
const presented = header.slice('Bearer '.length); const [prefix, secret] = presented.split('.'); if (!prefix || !secret) throw new ApiKeyError('malformed key');
const [row] = await db .select() .from(apiKeys) .where(eq(apiKeys.prefix, prefix)); if (!row || row.revokedAt) throw new ApiKeyError('invalid key');
// constant-time compare to prevent timing attack const presentedHash = await sha256Hex(secret); if (!timingSafeEqualHex(presentedHash, row.keyHash)) { throw new ApiKeyError('invalid key'); }
await db .update(apiKeys) .set({ lastUsedAt: new Date() }) .where(eq(apiKeys.id, row.id));
return { orgId: row.organizationId, createdByUserId: row.createdByUserId, scopes: row.scopes, };};Bump lastUsedAt, then return the resolved identity. On a match, record that the key was just used, then hand back the facts the wrapper needs to build ctx: the org the key belongs to, the human who created it (for the audit actor), and the key’s scopes. Notice what’s not here: no user, no role, no db. The wrapper assembles those, the same way it does for the cookie path.
A small honesty note on that resolved shape. The key resolves to an orgId and a createdByUserId, but the ctx the handlers expect also needs a user and a role. The wrapper fills those in by reading the creating member’s row in that org: the key acts under the org role of the human who minted it, looked up fresh at verify time. So a key minted by an admin acts as an admin, and if that human is later demoted, the key’s authority follows. (Its scopes then narrow it further, which is the next section.) For a true unattended service key whose creator was deleted, createdByUserId is null and the key acts as a machine principal. We keep that case simple and come back to the audit side of it shortly.
Notice, too, where the resolver reads the key from: the Authorization header, and nowhere else.
Now the change to the wrapper itself, which is almost anticlimactic. Here’s the resolve gate before and after, and the whole point is how little moves.
async (request, route) => { const { user, orgId, role: actorRole } = await requireOrgUser({ headers: request.headers, });
// …authorize (roleAtLeast) → parse → call fn — unchanged…};One door. Identity is always the session cookie, resolved by requireOrgUser. A caller with no cookie can’t get in.
async (request, route) => { const resolved = await resolveApiKey(request);
const { user, orgId, role: actorRole, scopes } = resolved ? await ctxFromKey(resolved) : await requireOrgUser({ headers: request.headers });
// …authorize (roleAtLeast) → parse → call fn — unchanged…};Two doors. resolveApiKey runs first. A Bearer header resolves through the key, where ctxFromKey reads the creating member to fill in user and role; anything else falls through to the same requireOrgUser as before. Both produce { user, orgId, role }, plus scopes for the key path. Everything below this point is byte-for-byte unchanged.
Look at what didn’t change. The authorize gate still runs roleAtLeast(actorRole, role), so a key is gated by exactly the same role check as a member, because by the time that gate runs a key is just a role in a ctx. The parse gate is untouched. The call gate is untouched. Every handler is untouched. One branch at the very top, and the entire authed surface accepts machine callers.
This is also where the chapter’s thesis lands, the reason this branch lives in the wrapper and nowhere else. The tempting shortcut, the one a junior reaches for under deadline, is to skip the wrapper and bolt an inline x-api-key check onto each route that needs it. Resist it completely. That’s the missing-check bug class you closed at the Server Action boundary, returning at a new seam: scatter the identity check across forty route handlers and you’ve signed up to get it right forty times, forever, including in the one a tired teammate adds on a Friday. Identity resolution belongs in one place, the wrapper, beside the cookie path, so that adding a new route handler gets key support for free and forgetting it becomes impossible. The wrapper was the structural fix for cookies, and it’s the structural fix for keys too. Same philosophy, same payoff: make the wrong call shape impossible, not merely discouraged.
Now you write the resolver yourself, because it’s the only genuinely new code in the lesson and it should pass through your hands. The exercise below gives you a working harness: an in-memory apiKeys store with one valid row, a sha256Hex shim, a constant-time timingSafeEqualHex shim, and a counter that records how many times the store was hit. Your job is to implement resolveApiKey(authHeader) so it passes every case.
Implement resolveApiKey(authHeader) — the Bearer branch of the wrapper. Given the Authorization header value (or null), resolve it to the key's identity or reject it. (1) No 'Bearer ' header → return null (not a key request, the wrapper falls through to the cookie path). (2) Slice off 'Bearer ' and split the token on the dot into prefix.secret — if either half is missing, throw new ApiKeyError('malformed key') and DON'T touch the store. (3) Look the row up with store.findByPrefix(prefix). (4) If there's no row, or row.revokedAt is set, throw new ApiKeyError('invalid key'). (5) Hash the presented secret with sha256Hex(secret) and compare it to row.keyHash with timingSafeEqualHex — never === — throwing ApiKeyError('invalid key') on mismatch. (6) On a match, return { orgId, createdByUserId, scopes } read off the row. The tests feed a valid key, a revoked key, a wrong secret, an unknown prefix, a malformed header (and assert the store was NEVER queried), and a no-Bearer header.
Reveal solution
const resolveApiKey = async (authHeader) => { if (!authHeader?.startsWith('Bearer ')) return null;
const presented = authHeader.slice('Bearer '.length); const [prefix, secret] = presented.split('.'); if (!prefix || !secret) throw new ApiKeyError('malformed key');
const row = store.findByPrefix(prefix); if (!row || row.revokedAt) throw new ApiKeyError('invalid key');
const presentedHash = await sha256Hex(secret); if (!timingSafeEqualHex(presentedHash, row.keyHash)) { throw new ApiKeyError('invalid key'); }
return { orgId: row.organizationId, createdByUserId: row.createdByUserId, scopes: row.scopes, };};The order is the lesson. The structural check, whether this even has a prefix.secret shape, runs before the store lookup, which is why the malformed-header test asserts zero store hits: you never pay for a database round-trip on a string that isn’t key-shaped. The missing-or-revoked check then rejects a row that exists but isn’t valid. And the secret check hashes first, then compares with timingSafeEqualHex, never ===, so the wrong key and the revoked key both come back as the same opaque “invalid,” giving an attacker nothing to distinguish them by. Everything the real wrapper does on top of this, from turning the throw into a 401, to reading the creating member for role, to building ctx, sits around this function. The resolution itself is exactly these steps.
Scopes: the key’s ceiling, never above its role
Section titled “Scopes: the key’s ceiling, never above its role”A key now resolves to a role. But a role is a blunt instrument for a credential you mailed to a partner: “admin” lets them do everything an admin can, when all they need is to push invoices nightly. So a key carries a second, finer gate, its scopes, and the rule that ties the two together is the one to remember: a key can never exceed its role, and usually does far less.
Hold the two gates apart, because they answer different questions:
- Role answers who is this, and how powerful are they in the org? It’s the gate you already have,
roleAtLeastin the wrapper, checked identically for keys and humans. - Scope answers of everything this caller’s role could do, how much did the issuer actually grant this specific key? It’s checked after the role gate, inside the handler, with a tiny helper.
The relationship between them is an intersection, and the narrowest side wins. The following figure shows it.
invoices:readinvoices:writemembers:managesettings:edit invoices:read invoices:readinvoices:writemembers:managesettings:edit In code, scopes only mean something when the caller is a key. A human at the dashboard isn’t a scoped credential; they operate with the full reach of their role, full stop. So ctx gains an optional scopes field: an array when a key resolved the request, absent when a cookie did. The hasScope helper reads it and treats “absent” as “no ceiling”:
export const hasScope = (ctx: Ctx, scope: string): boolean => ctx.scopes === undefined || ctx.scopes.includes(scope);That ctx.scopes === undefined branch is the load-bearing line. For a cookie caller, scopes is undefined and hasScope returns true for everything, so the human’s role is their only ceiling, exactly as before this lesson. For a key caller, scopes is the granted array and hasScope returns true only for scopes actually in it. The same handler asks the same question, hasScope(ctx, 'invoices:write'), and gets the right answer for both kinds of caller, with no branching in the handler itself.
A handler that mutates invoices then reads like this, with the role gate at the door (in the wrapper) and the scope gate in the body:
export const POST = authedRoute( 'member', createInvoiceSchema, async (input, ctx) => { if (!hasScope(ctx, 'invoices:write')) { return problem(403, 'This key is not scoped to write invoices.'); } const result = await createInvoice(input, ctx); return result.ok ? Response.json(result.data, { status: 201 }) : problemFrom(result.error); },);The rule to carry away is that scopes must narrow, never widen. A scope can take an admin-roled key and restrict it to read-only; it can never take a member-roled key and let it do something a member can’t. The role is the ceiling, and scopes only carve a smaller box beneath it. That’s least privilege made concrete for a credential that lives on someone else’s server: you grant the minimum the integration needs, and a leak of that key can do only that minimum, not everything its role could theoretically reach.
(That scopes array is the same one the api-key.created audit payload recorded a few sections back: one source of truth for what a key may do, written once at mint time and read at every request.)
This distinction is easy to get backwards, so check it.
A partner’s key is scoped to invoices:read only, but it was minted by an admin — so the key acts under the admin role. A request carrying this key hits DELETE /api/invoices/:id. The route’s wrapper requires the admin role, and the handler body calls hasScope(ctx, 'invoices:write') before deleting. What does the caller get back?
403 from inside the handler. The wrapper lets the request through, then the body’s hasScope check stops it.204 — the delete goes through, because the key carries admin authority and an admin may delete invoices.403 from the wrapper’s role gate, before the handler body ever runs.204 — hasScope waves through any caller whose role is admin or higher.403 raised inside the handler. The two gates are independent, and the narrowest one wins. The key really does act with admin authority, so the wrapper’s roleAtLeast(role, 'admin') passes and the request reaches the body. But scopes are a second, tighter ceiling checked there: this key was granted only invoices:read, so hasScope(ctx, 'invoices:write') returns false and the handler returns 403. A scope can only narrow what the role allows — never widen it, and never be skipped just because the role is high. That is the whole point of mailing a partner a key scoped below its role: even with admin authority behind it, the key does only the one thing you granted.The machine actor in the audit trail
Section titled “The machine actor in the audit trail”A key-authenticated request has no human at the keyboard. So when such a request does something audit-worthy, creating an invoice, say, what does the audit row record as the actor?
You already built the answer into the audit table; you just hadn’t used it yet. Recall that actorUserId is nullable, with onDelete: 'set null', precisely so the actor column can express “no specific human.” A key-authored audit row uses that in two ways:
actorUserIdis the key’screatedByUserId, so the human who minted the key is accountable for what it does. A request made with Acme’s nightly-sync key is attributed to the admin who created that key, which is exactly what an auditor wants to know when they ask “who is responsible for this automated action?”- Or it’s
null, for a true unattended service key whose creator was deleted or who was never a specific person. This is the same nullable-actor case as the scheduled-job and webhook events from the audit lesson: anullactor is information, not a missing value. It says “a machine did this,” and the auditpayloadcarries which one, the key’sprefix, so “which key did this?” stays answerable even when “which human?” has no answer.
There’s a wrinkle, and it follows straight from how the audit lesson built the helper: logAudit reads the session, so a session-less writer must insert the row directly. Recall how logAudit(tx, event) works. It takes only the transaction and the event, then derives the actor and org itself, from requireOrgUser() and await headers(), both of which read the session cookie. A key caller has no cookie. Call logAudit on a key-authenticated request and requireOrgUser() throws, because there’s no session to read. That’s the same gap the audit lesson’s system actor hit, since the scheduled job and the webhook also have no session for the helper to read from. The resolution is the same as for those non-cookie actors: when there’s no session to derive “who” and “which org” from, you insert the audit_logs row by hand, sourcing the actor and org from what you do have. The API key is just one more non-cookie actor.
So a key-authored audit row is a direct insert on the same tx as the work, inside the wrapper’s withTenant(ctx.orgId, …) scope. It sources organizationId from ctx.orgId, actorUserId from the resolved creator ctx.user (or null for an unattended service key whose creator was deleted), and carries the key’s prefix in the payload so the trail points back at the exact credential:
await tx.insert(auditLogs).values({ organizationId: ctx.orgId, actorUserId: ctx.user?.id ?? null, // the key's creator, or null action: 'invoice.created', subjectType: 'invoice', subjectId: invoice.id, payload: { viaApiKey: prefix },});The discipline is identical to every other audit write in this chapter: the row still rides inside the same transaction as the work, so it exists if and only if the work landed. Only the source of “who” and “which org” changes, from a session cookie read by the helper to the identity the wrapper already resolved from the key. (The columns logAudit fills from the headers, actorIp and actorUserAgent, are simply absent here. A machine caller behind a partner’s backend has no meaningful browser context, and both columns are nullable.)
Tie this back to the bigger taxonomy you’ll meet in the security chapter later in the course. That chapter names four kinds of actor an audit row can carry: a human, the system, an API key, and a webhook. The API key is one of those four. The detail worth noting now is the same one that chapter makes: there is no actorType column, and you don’t invent one. The distinction between the four kinds is carried entirely by whether actorUserId is null, plus the action string and the payload. The schema you already have expresses all four, so resist the urge to add a column the existing shape doesn’t need.
Personal access tokens: the same machinery, one owner swap
Section titled “Personal access tokens: the same machinery, one owner swap”Here’s the reuse payoff, and it’s a clean one. Everything you just built, the prefix.secret shape, the hash-and-show-once storage, the constant-time verify branch, and the Authorization: Bearer door, becomes a personal access token with a single change: swap the owner.
An org key is owned by an organization and acts under an org role. A personal access token is owned by a user and acts as that user. It’s the credential an engineer mints for their own scripts, their own CLI, their own automation, carrying their own identity rather than an org’s. Same prefix, same keyHash, same constant-time compare, same Bearer header. Two things differ:
- The owner column, which decides whose identity
ctxresolves to. An org key resolvesctxto an org and a role; a PAT resolves it to the user who owns the token. - The lifecycle. A PAT is tied to a person, so it’s hard-deleted when that person’s account is erased. It’s exactly the “personal API keys” entry in the account-erasure job the security chapter builds later. (Note the asymmetry with org-key revoke: an org key is revoked and kept for the trail, while a PAT is deleted with its owner, because the owner’s right to erasure outranks the key’s trail.)
The following comparison makes the one swap concrete, with the same columns and the owner moved.
{ prefix: 'rsk_live_ab12cd34', keyHash, organizationId: 'org_1', createdByUserId: 'user_1', scopes: ['invoices:read'],}The owner is organizationId. The key acts inside that org, under the role of the member who created it. Revoke sets revokedAt, and the row survives for the trail.
{ prefix: 'rsk_pat_ef56gh78', keyHash, userId: 'user_1', createdByUserId: 'user_1', scopes: ['invoices:read'],}The one changed line is the owner: userId where the org key had organizationId. Everything else is identical. The token acts as that user, with the user’s own authority, and it’s deleted, not just revoked, when the user’s account is erased.
The judgment to take away is to not build two systems. It’s tempting to see “org keys” and “personal tokens” as two features and write two tables, two verify paths, and two sets of mint/revoke actions. Don’t. It’s one mechanism, one verify branch and one hash-and-show-once posture, and the owner column is the only thing that decides whose identity the resolved ctx carries. (In a real schema you’d express this as one table with a nullable organizationId and a nullable userId, exactly one of which is set, or as two thin tables sharing the verify helper. Either way, one verification path serves both.) We keep this lesson’s worked example to the org-key shape, and the PAT is that shape with the owner swapped, so naming it is enough to see the reuse.
Consume the library: Better Auth’s apiKey plugin
Section titled “Consume the library: Better Auth’s apiKey plugin”Step back, because this is the same beat you hit with the organization plugin earlier in the course, and it’s the chapter’s recurring discipline.
You hand-built the API-key mechanism end to end: the table, the hash-and-show-once storage, the verify branch, and the scopes. You did that so you understand what a key is: what’s stored, what’s compared, what makes a leak survivable, and why the secret is shown once. That understanding is the deliverable. In a real project, though, you would not ship this hand-rolled table. You’d reach for Better Auth’s apiKey plugin, which does the hashing, the scoped permissions, expiry, and per-key rate limiting for you. It’s the same “consume the library directly” move (Architectural Principle #5) you made when you let the organization plugin own roles and memberships.
The plugin hashes keys at rest by default, so the posture you just hand-built is its out-of-the-box behavior, not something you configure. It supports a custom prefix, scoped permissions, an expiresIn for keys that should age out on their own, and per-key rate limiting. Its server methods (auth.api.createApiKey(), auth.api.verifyApiKey()) are the production replacements for the createApiKey and resolveApiKey you wrote by hand. Two honest notes on the boundary between what you built and what the plugin gives you. First, the plugin models permissions as a structured object (something like { invoices: ['read'] }) rather than the flat invoices:read strings we used; our flat array was the teaching simplification, and the plugin’s richer shape is the same idea with more structure. Second, the plugin owns the hashing and storage you just hand-rolled, so configuring it replaces the table you built rather than sitting on top of it. The hand-built version is the teaching artifact; the plugin is the path you ship.
The judgment that runs through this whole chapter is to hand-roll to learn the shape and adopt the plugin to ship. It’s the same call as tenantDb being a thin sanctioned wrapper rather than a homegrown ORM, and the same as consuming the org plugin directly instead of reimplementing memberships.
One forward pointer the plugin makes possible: it can key its rate limiter per API key, which is where this meets the rate-limiting chapter later in the course and its safeLimit wrapper. A leaked or runaway key hammering your API gets throttled on its own key, without affecting anyone else’s. We name that seam here and build nothing, because rate limiting is its own chapter.
Where this goes next
Section titled “Where this goes next”Step back and place this lesson in the chapter’s spine. You’ve now seen “make the wrong shape impossible” land for a fourth time. tenantDb made the missing tenant filter not compile. authedAction made the missing role check not compile. logAudit made the off-transaction audit write not compile. And now identity for a machine resolves at the same boundary as the session cookie, into the same ctx, so authorization, tenancy, and audit all keep working with zero changes downstream. The route handler was never a weaker door. It now has two doors, and both are fenced by the same wrapper.
A handful of threads run on from here, named so you know where they live, not built:
- OAuth 2.1 client-credentials, the third-party-app machine-identity model the course named in the auth mental model. It’s the heavier reach for when you’re a platform other people’s apps integrate against; the stored-hash key is the year-one default for your own partners.
- Per-key rate limiting, the seam where the
apiKeyplugin meets the Upstash limiter later in the course. - Key rotation and expiry schedules beyond manual revoke, meaning automatic aging-out and overlapping old-and-new keys during a rotation window. Named once; the plugin’s
expiresInis the hook. - The PAT hard-delete, the personal-token half of the account-erasure job the security chapter builds.
- Invitation tokens, next chapter, the one to watch for, because you’re about to see this exact posture again. The store-the-hash, show-once shape you built here is precisely how the next chapter mints and verifies invitation tokens. You built the primitive once, and you’ll recognize it the moment it returns.
External resources
Section titled “External resources”The production path: hashing at rest, scoped permissions, expiry, and per-key rate limiting, configured rather than hand-rolled. The replacement for this lesson's table.
The vendor-neutral case for the posture this lesson takes: hash at rest, show once, least privilege, and never a secret in a URL or a log.
The CSPRNG behind the secret half of every key, the same randomness source you used for tokens earlier in the course.