Back to Blog
Nov 28, 20254 min readOnuzulike Anthony

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.

SecuritySecurityAPIAuth

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.

ts
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 (live vs test)
  • 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.

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

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

ts
type Scope =
  | 'read:data'
  | 'write:data'
  | 'delete:data'
  | 'admin:all';

Store them as a JSON array on the key record. Validate on every request:

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

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

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