API key design: format, storage, and rotation without breaking clients

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);
}
Show the full key exactly once, immediately after creation. After the user closes the dialog, you can only show the last 4 characters. This mirrors Stripe's behavior and sets the right expectation — it also motivates users to immediately save the key to their secret manager.

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.