API keys are the workhorses of machine-to-machine authentication. They're simpler than OAuth client credentials, easier to embed in CI/CD pipelines and server environments, and require no token refresh logic. But "simpler" is relative — there are several ways to get API key design badly wrong, and most of them result in either a security incident or a painful developer experience. This post covers the full design from key format through storage, rotation, and the long-lived vs short-lived tradeoff.
Key format: prefix-based design
The format of your API keys affects security in a non-obvious way: if a key accidentally ends up in a log file, a GitHub commit, or a support ticket, it needs to be identifiable as a key so it can be revoked immediately. Random strings like f8a2c1b9d3e4... blend into log noise. Prefixed keys like bstn_live_f8a2c1b9d3e4... are instantly recognizable.
This matters because GitHub's secret scanning, GitGuardian, and similar tools operate by pattern-matching prefixes. If you publish your key format, these tools can automatically flag leaked keys in public repositories and alert you before they're exploited.
type KeyEnvironment = 'live' | 'test';
type KeyType = 'sk' | 'pk'; // secret key or publishable key
interface ApiKeyComponents {
prefix: string; // e.g. "bstn"
env: KeyEnvironment;
type: KeyType;
entropy: string; // 24 random bytes, base58-encoded
checksum: string; // last 4 chars for display/lookup
}
function generateApiKey(env: KeyEnvironment, type: KeyType): string {
// 24 bytes = 192 bits of entropy
const entropyBytes = require('crypto').randomBytes(24);
const entropy = base58Encode(entropyBytes); // ~33 chars
// Format: bstn_live_sk_ENTROPY
return `bstn_${env}_${type}_${entropy}`;
}
// Examples:
// bstn_live_sk_3mNpQx7vKjRtY2wLhFdCaBnEgX9ZuV (secret key, live)
// bstn_test_sk_8kPmWqAsBnDfHjLrCvYxNtZe4R6gT1 (secret key, test)
// bstn_live_pk_2qRsUo5mKhJyXwCfVdBnPzYaE8TgL7 (publishable key, live)
Using base58 (Bitcoin's alphabet: no 0, O, I, l) makes keys safe to type from screenshots — no ambiguous characters. The total key length of ~40 characters is comfortable for embedding in environment files and curl commands.
Hashed storage: never store raw keys
API keys must never be stored in plaintext. If your database is compromised, the attacker should get hashes, not working keys. Use SHA-256 for storage — unlike passwords, API keys have enough entropy (192 bits) that bcrypt's slow hashing is unnecessary. The threat model is a database dump, not brute-force guessing.
import { createHash, randomBytes } from 'crypto';
function hashApiKey(rawKey: string): string {
return createHash('sha256').update(rawKey).digest('hex');
}
// When creating a key:
async function createApiKey(
orgId: string,
name: string,
env: KeyEnvironment,
db: DB
): Promise<{ key: string; record: ApiKeyRecord }> {
const rawKey = generateApiKey(env, 'sk');
const keyHash = hashApiKey(rawKey);
const last4 = rawKey.slice(-4); // last 4 chars of entropy for display
const record = await db.apiKeys.create({
orgId,
name,
environment: env,
keyHash,
last4,
createdAt: new Date(),
expiresAt: null, // null = never expires (user-controlled)
});
// Return the raw key ONCE — it's never retrievable again
return { key: rawKey, record };
}
// When validating a request:
async function lookupApiKey(rawKey: string, db: DB): Promise<ApiKeyRecord | null> {
const keyHash = hashApiKey(rawKey);
return db.apiKeys.findByHash(keyHash);
}
The last-4 display pattern
Users need to identify which key is which in the dashboard without exposing the full value. The standard pattern stores only the last 4 characters of the key for display. Combined with the key name, creation date, and last-used timestamp, this is sufficient for identification:
Name Environment Last 4 Created Last Used
──────────────── ─────────── ─────── ─────────────── ──────────────
Production API live ...V7r2 Apr 21, 2025 2 hours ago
CI Pipeline test ...N8k5 Apr 15, 2025 Yesterday
Staging Hook test ...P3qL Mar 30, 2025 Never
The "Never" last-used indicator is important — it identifies keys that were created but never put into service, which are candidates for cleanup.
Key rotation strategy
Key rotation — replacing an old key with a new one — is the most disruptive part of API key lifecycle management. Done wrong, it takes down production systems. The correct rotation flow introduces a grace period:
async function initiateKeyRotation(keyId: string, db: DB): Promise<RotationResult> {
const oldKey = await db.apiKeys.findById(keyId);
if (!oldKey) throw new Error('Key not found');
// Create the new key immediately
const { key: newRawKey, record: newRecord } = await createApiKey(
oldKey.orgId,
`${oldKey.name} (rotated)`,
oldKey.environment,
db
);
// Set a rotation expiry on the OLD key — it stays valid for 7 days
await db.apiKeys.update(oldKey.id, {
rotatingExpiry: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
replacedBy: newRecord.id,
});
return { newKey: newRawKey, expiresAt: rotatingExpiry };
}
// In key validation middleware:
async function validateKey(rawKey: string, db: DB): Promise<AuthContext | null> {
const record = await lookupApiKey(rawKey, db);
if (!record) return null;
// Check hard revocation
if (record.revokedAt) return null;
// Check if this is a rotating key past its grace period
if (record.rotatingExpiry && new Date() > record.rotatingExpiry) return null;
// If the key is in rotation, emit a deprecation warning header
const isRotating = record.rotatingExpiry != null;
return {
orgId: record.orgId,
keyId: record.id,
environment: record.environment,
deprecationWarning: isRotating
? `This API key expires on ${record.rotatingExpiry.toISOString()}. Rotate to key ending ...${record.replacedByLast4}.`
: null,
};
}
Return the deprecation warning in a response header so the client developer sees it in their logs during the grace period:
if (authContext.deprecationWarning) {
res.setHeader('X-API-Key-Warning', authContext.deprecationWarning);
res.setHeader('Deprecation', 'true');
res.setHeader('Sunset', authContext.rotatingExpiry.toUTCString());
}
Key scoping and permissions
A single organization should be able to create keys with different permission scopes — a read-only key for analytics dashboards, a write key for CI pipelines, an admin key for provisioning automation. This follows the principle of least privilege: if a CI key is leaked, the attacker can only do what the CI system can do.
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
key_hash CHAR(64) NOT NULL UNIQUE, -- SHA-256 hex
last4 CHAR(4) NOT NULL,
environment TEXT NOT NULL CHECK (environment IN ('live', 'test')),
scopes TEXT[] NOT NULL DEFAULT '{}', -- e.g. {'read:users', 'write:users'}
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
rotating_expiry TIMESTAMPTZ,
replaced_by UUID REFERENCES api_keys(id),
expires_at TIMESTAMPTZ -- null = never
);
Long-lived keys vs short-lived tokens
The tradeoff between long-lived API keys and short-lived access tokens comes down to operational complexity vs security posture:
- Long-lived keys (no expiry or yearly rotation): simple to use, easy to embed in environment variables, but a leaked key is a long-lived credential. Appropriate for server-to-server use in well-controlled environments.
- Short-lived tokens (hourly, via client credentials flow): higher operational overhead (clients must implement token refresh), but a leaked token expires quickly. Better for high-security environments or where keys might transit less-trusted systems.
Offer both. Let organizations choose based on their security requirements. Stripe, for example, offers long-lived keys as the default with restricted keys as a security enhancement. For enterprise customers with SOC 2 or ISO 27001 requirements, mandatory expiry on all keys may be a compliance requirement — build support for this from day one.