API Key Design: Hashing, Scoping, and Rotation
How to design API keys that are safe to store, easy to identify in logs, scoped to least privilege, and revocable without breaking everything.
API keys are deceptively simple. They're just a secret string — but the details of how you generate, store, scope, and revoke them determine whether a compromise is a minor incident or a catastrophe.
Generating Keys
Keys should be cryptographically random, long enough to be brute-force resistant, and URL-safe. 32 random bytes, base64url-encoded, gives you 256 bits of entropy — that's sufficient.
import { randomBytes } from 'crypto';
function generateKey(): string {
return randomBytes(32).toString('base64url');
}This gives you strings like X4mN9pQzKwR8vL2jHbTcFdEuYsAiOgP1. No ambiguous characters, no URL encoding needed.
The Prefix Scheme
Stripe popularized this and it's now standard practice: prefix keys with a meaningful, human-readable identifier.
sk_live_X4mN9pQzKwR8vL2jHbTcFdEuYsAiOgP1
sk_test_Y7kL3mNpRwQ9vM4jIcUdGeVxZtBjPhS2
pk_live_Z2nP6qTsLxU0wN5kJdVeHfWyArCkMiT3
The prefix tells you:
- What environment (
livevstest) - What type of key (
sk= secret,pk= publishable) - That you're looking at an API key at all (useful in logs and code review)
GitHub's token scanner and Stripe's own breach detection use these prefixes to identify accidentally committed secrets in public repos.
Never Store the Raw Key
Once generated, the key is shown to the user once and never again. What you store is a hash.
import { createHash } from 'crypto';
async function createApiKey(userId: string, scope: string[]) {
const key = `sk_live_${generateKey()}`;
const hash = createHash('sha256').update(key).digest('hex');
await db.insert(apiKeys).values({
userId,
keyHash: hash,
prefix: key.substring(0, 12), // "sk_live_X4mN" for display
scope,
createdAt: new Date(),
lastUsedAt: null,
});
return key; // shown once, never stored
}On each API request, hash the incoming key and look it up:
const hash = createHash('sha256').update(incomingKey).digest('hex');
const keyRecord = await db.select().from(apiKeys)
.where(eq(apiKeys.keyHash, hash));Store the prefix for display purposes — so users can identify "which key is this?" in your dashboard without exposing the full key.
Scoping
Keys should carry the minimum permissions needed. A key for reading metrics shouldn't be able to delete resources.
Define scopes as a string enum:
type Scope =
| 'read:data'
| 'write:data'
| 'delete:data'
| 'admin:all';Store them as a JSON array on the key record. Validate on every request:
function requireScope(key: ApiKey, required: Scope) {
if (!key.scope.includes(required) && !key.scope.includes('admin:all')) {
throw new AuthorizationError(`Scope '${required}' required`);
}
}Expose scope selection in your key creation UI. Users who only need read access shouldn't be creating write-scope keys by default.
Rotation
Design for key rotation from day one. Rotation should be non-breaking: issue a new key, allow both to work during a transition period, then revoke the old one.
async function rotateKey(oldKeyId: string, userId: string) {
const newKey = await createApiKey(userId, existingKey.scope);
// Old key still valid — user migrates their systems
await db.update(apiKeys)
.set({ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) })
.where(eq(apiKeys.id, oldKeyId));
return newKey;
}Set an expiry on the old key rather than deleting it immediately. A 7-day grace period lets users migrate without a hard cutover.
Revocation
Revocation must be instant. Because you're looking up by hash on every request, marking a key as revoked in the DB takes effect immediately on the next request — no cache to invalidate, no token expiry to wait for.
This is the primary advantage of API keys over JWTs for machine-to-machine auth: deterministic, immediate revocation.
Audit Trail
Record lastUsedAt, the requesting IP, and the endpoint on every key use. When a key is compromised, you want to know what it accessed.
await db.update(apiKeys)
.set({ lastUsedAt: new Date() })
.where(eq(apiKeys.id, keyRecord.id));Keep a separate api_key_events table for the full audit log — don't put it on the key record itself, or your key table becomes a write-heavy bottleneck.