API Key Security: Generation and Rotation

API keys are the skeleton keys to your application. A single compromised key can expose customer data, enable unauthorized access, and rack up massive bills on your infrastructure. Despite this, most...

Key Insights

  • API keys must be generated using cryptographically secure random number generators with at least 256 bits of entropy—never use UUIDs, sequential IDs, or predictable patterns.
  • Store API keys hashed (not encrypted) in your database using bcrypt or Argon2, keeping only a short prefix for identification and customer support lookups.
  • Implement graceful rotation with overlap periods where both old and new keys remain valid temporarily, preventing service disruption during credential updates.

Introduction: Why API Key Security Matters

API keys are the skeleton keys to your application. A single compromised key can expose customer data, enable unauthorized access, and rack up massive bills on your infrastructure. Despite this, most teams treat API key security as an afterthought—generating weak keys, storing them in plaintext, and never rotating them.

The attack vectors are numerous: keys leaked in Git commits, exposed in client-side code, stolen from compromised developer machines, or extracted from poorly secured databases. In 2023, GitGuardian detected over 10 million secrets exposed in public GitHub repositories. Many of those were API keys.

Proper generation and rotation aren’t optional security theater—they’re fundamental controls that limit blast radius when (not if) a key gets compromised.

Secure API Key Generation

The foundation of API key security is cryptographic randomness. Your keys must be unpredictable, meaning an attacker who knows a thousand of your existing keys should have zero advantage in guessing the next one.

Never use these for API key generation:

  • UUIDs (only 122 bits of randomness, often poorly implemented)
  • Sequential database IDs with encoding
  • Timestamps with random suffixes
  • Hash of user data (email, user ID, etc.)

Instead, use your language’s cryptographically secure random number generator with at least 32 bytes (256 bits) of entropy.

# Python: Secure API key generation
import secrets
import base64

def generate_api_key(prefix: str = "sk") -> str:
    """
    Generate a cryptographically secure API key.
    Format: prefix_base64encoded(32 random bytes)
    Example: sk_a3JhbmRvbWJ5dGVzaGVyZQ...
    """
    random_bytes = secrets.token_bytes(32)
    encoded = base64.urlsafe_b64encode(random_bytes).decode('utf-8').rstrip('=')
    return f"{prefix}_{encoded}"

# Generate different key types
live_key = generate_api_key("sk_live")
test_key = generate_api_key("sk_test")
// Node.js: Secure API key generation
const crypto = require('crypto');

function generateApiKey(prefix = 'sk') {
  const randomBytes = crypto.randomBytes(32);
  const encoded = randomBytes
    .toString('base64url')
    .replace(/=/g, '');
  return `${prefix}_${encoded}`;
}

// Usage
const liveKey = generateApiKey('sk_live');
const testKey = generateApiKey('sk_test');

The prefix serves multiple purposes: it identifies key types (live vs. test, read-only vs. full access), enables quick visual identification, and helps security scanners detect leaked keys. Stripe popularized this pattern, and you should adopt it.

Storage and Hashing Best Practices

Here’s a rule that surprises many developers: never store API keys in plaintext or encrypted form. Hash them.

API keys are bearer tokens—possession equals authorization. This makes them functionally identical to passwords from a storage perspective. If an attacker dumps your database, encrypted keys can be decrypted if they also obtain the encryption key. Hashed keys are useless without the original value.

The challenge is that unlike passwords, you need to look up API keys on every request. The solution: store a short prefix in plaintext for identification, and the full hashed key for verification.

# Python: API key storage with hashing
import hashlib
import argon2
from dataclasses import dataclass
from datetime import datetime, timedelta

# Use Argon2 for key hashing (preferred over bcrypt for new systems)
hasher = argon2.PasswordHasher(
    time_cost=2,
    memory_cost=65536,
    parallelism=1
)

@dataclass
class StoredApiKey:
    id: str
    prefix: str  # First 8 chars, stored plaintext for lookup
    key_hash: str  # Argon2 hash of full key
    user_id: str
    name: str
    scopes: list
    created_at: datetime
    expires_at: datetime | None
    last_used_at: datetime | None
    is_revoked: bool = False

def store_api_key(raw_key: str, user_id: str, name: str, scopes: list) -> StoredApiKey:
    """Hash and store an API key. Return the stored record."""
    prefix = raw_key[:12]  # e.g., "sk_live_a3Jh"
    key_hash = hasher.hash(raw_key)
    
    return StoredApiKey(
        id=generate_uuid(),
        prefix=prefix,
        key_hash=key_hash,
        user_id=user_id,
        name=name,
        scopes=scopes,
        created_at=datetime.utcnow(),
        expires_at=datetime.utcnow() + timedelta(days=90),
        last_used_at=None,
    )

def verify_api_key(raw_key: str, stored: StoredApiKey) -> bool:
    """Verify a raw API key against stored hash."""
    if stored.is_revoked:
        return False
    if stored.expires_at and datetime.utcnow() > stored.expires_at:
        return False
    try:
        hasher.verify(stored.key_hash, raw_key)
        return True
    except argon2.exceptions.VerifyMismatchError:
        return False

For lookup efficiency, create a database index on the prefix column. When a request comes in, query by prefix first, then verify the hash. This keeps verification fast even with millions of keys.

Implementing Key Rotation Strategies

Key rotation limits the window of exposure when keys are compromised. Implement rotation for these triggers:

  1. Time-based: Automatically expire keys after 90 days
  2. Suspected compromise: Immediate revocation with new key generation
  3. Employee offboarding: Rotate all keys the departing employee had access to
  4. Scope changes: Generate new keys when permissions change

The critical detail is graceful rotation. Never invalidate the old key the instant a new one is created. Provide an overlap period where both keys work, giving clients time to update their configurations.

-- PostgreSQL schema for multi-key support with rotation
CREATE TABLE api_keys (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    prefix VARCHAR(16) NOT NULL,
    key_hash VARCHAR(255) NOT NULL,
    user_id UUID NOT NULL REFERENCES users(id),
    name VARCHAR(255) NOT NULL,
    scopes JSONB DEFAULT '[]',
    
    -- Rotation support
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    expires_at TIMESTAMPTZ,
    revoked_at TIMESTAMPTZ,
    rotation_deadline TIMESTAMPTZ,  -- Old key valid until this time
    replaced_by_id UUID REFERENCES api_keys(id),
    
    -- Auditing
    last_used_at TIMESTAMPTZ,
    last_used_ip INET,
    use_count BIGINT DEFAULT 0,
    
    CONSTRAINT valid_expiration CHECK (expires_at IS NULL OR expires_at > created_at)
);

CREATE INDEX idx_api_keys_prefix ON api_keys(prefix) WHERE revoked_at IS NULL;
CREATE INDEX idx_api_keys_user ON api_keys(user_id) WHERE revoked_at IS NULL;
CREATE INDEX idx_api_keys_expiring ON api_keys(expires_at) WHERE revoked_at IS NULL;

Building a Rotation API

Your API key management endpoints need careful design. They must be authenticated (usually with an existing valid key or session), rate-limited, and comprehensively logged.

# FastAPI: Key rotation endpoints
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel

router = APIRouter(prefix="/api-keys", tags=["API Keys"])

class KeyRotateRequest(BaseModel):
    current_key_id: str
    overlap_hours: int = 24  # How long both keys remain valid

class KeyRotateResponse(BaseModel):
    new_key: str  # Only time the raw key is returned
    new_key_id: str
    old_key_expires_at: datetime
    
@router.post("/rotate", response_model=KeyRotateResponse)
async def rotate_api_key(
    request: KeyRotateRequest,
    current_user: User = Depends(get_authenticated_user),
    db: Database = Depends(get_db),
):
    # Verify ownership of the key being rotated
    old_key = await db.get_api_key(request.current_key_id)
    if not old_key or old_key.user_id != current_user.id:
        raise HTTPException(404, "API key not found")
    
    if old_key.is_revoked:
        raise HTTPException(400, "Cannot rotate a revoked key")
    
    # Generate new key with same scopes
    raw_new_key = generate_api_key("sk_live")
    new_key_record = store_api_key(
        raw_key=raw_new_key,
        user_id=current_user.id,
        name=f"{old_key.name} (rotated)",
        scopes=old_key.scopes,
    )
    
    # Set rotation deadline on old key
    rotation_deadline = datetime.utcnow() + timedelta(hours=request.overlap_hours)
    await db.update_api_key(old_key.id, {
        "rotation_deadline": rotation_deadline,
        "replaced_by_id": new_key_record.id,
    })
    
    # Audit log
    await audit_log.record(
        action="api_key.rotated",
        user_id=current_user.id,
        details={
            "old_key_id": old_key.id,
            "new_key_id": new_key_record.id,
            "overlap_hours": request.overlap_hours,
        }
    )
    
    return KeyRotateResponse(
        new_key=raw_new_key,  # Only returned once, never stored
        new_key_id=new_key_record.id,
        old_key_expires_at=rotation_deadline,
    )

Automated Rotation and Monitoring

Manual rotation doesn’t scale. Integrate with secrets managers and CI/CD pipelines to automate the process.

# GitHub Actions: Automated API key rotation
name: Rotate API Keys

on:
  schedule:
    - cron: '0 0 1 */3 *'  # Quarterly rotation
  workflow_dispatch:  # Manual trigger

jobs:
  rotate-keys:
    runs-on: ubuntu-latest
    steps:
      - name: Rotate production API key
        env:
          VAULT_ADDR: ${{ secrets.VAULT_ADDR }}
          VAULT_TOKEN: ${{ secrets.VAULT_TOKEN }}
        run: |
          # Get current key from Vault
          CURRENT_KEY=$(vault kv get -field=api_key secret/prod/api-credentials)
          
          # Call rotation API
          RESPONSE=$(curl -X POST https://api.example.com/api-keys/rotate \
            -H "Authorization: Bearer ${CURRENT_KEY}" \
            -H "Content-Type: application/json" \
            -d '{"overlap_hours": 48}')
          
          NEW_KEY=$(echo $RESPONSE | jq -r '.new_key')
          
          # Store new key in Vault
          vault kv put secret/prod/api-credentials api_key="${NEW_KEY}"
                    
      - name: Notify team
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {"text": "API keys rotated successfully. Old keys expire in 48 hours."}            

Set up monitoring for:

  • Keys approaching expiration (alert at 14, 7, and 1 day)
  • Keys that haven’t been rotated in 90+ days
  • Unusual usage patterns (geographic anomalies, request volume spikes)
  • Failed authentication attempts by prefix

Conclusion: Security Checklist

Before shipping your API key system, verify these controls:

  • Keys generated with cryptographically secure randomness (32+ bytes)
  • Keys include type-identifying prefixes
  • Keys stored as hashes, never plaintext or encrypted
  • Prefix stored separately for lookup efficiency
  • Expiration dates enforced on all keys
  • Rotation API with configurable overlap periods
  • Audit logging for all key lifecycle events
  • Automated rotation integrated with secrets management
  • Monitoring and alerting on key age and usage anomalies
  • Revocation immediately effective (no caching issues)

API keys are a pragmatic choice for service-to-service authentication, but they’re not the only option. For user-facing applications, consider OAuth 2.0 with short-lived tokens. For internal services, mutual TLS or SPIFFE/SPIRE provide stronger identity guarantees. But when you do use API keys, implement them properly—your future self dealing with a breach investigation will thank you.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.