API Key Management: Generation, Storage, Rotation & Security

Type: Software Reference Confidence: 0.88 Sources: 7 Verified: 2026-02-24 Freshness: 2026-02-24

TL;DR

Constraints

Quick Reference

AspectBest PracticeExampleRationale
GenerationUse CSPRNG, 32+ bytes (256 bits entropy)crypto.randomBytes(32)Prevents brute-force and prediction attacks
FormatPrefix + base62/hex payloadsk_live_a1b2c3... (Stripe style)Prefix enables leak detection and environment routing
StorageHash with SHA-256, store only the digestSHA-256(key) in DB, raw key shown onceCompromised DB does not expose usable keys
TransmissionAuthorization: Bearer <key> header over HTTPSNever in URL query paramsURLs are logged in server logs, proxies, and browser history
RotationDual-key overlap window (7-30 days)Create new key, migrate, revoke oldZero-downtime rotation without breaking consumers
RevocationImmediate hard-delete or soft-delete with TTLDelete hash from DB, return 401 on next useLimits blast radius of compromised keys
ScopingPer-key permission set (read/write/admin)Google Cloud API restrictionsLeast privilege — limits damage from a leaked key
Rate LimitingPer-key rate limit, not just per-IP100 req/min per key, 429 on exceedPrevents abuse and identifies heavy consumers
LoggingLog key prefix + action, never the full keysk_live_a1b2... accessed /api/v1/usersAudit trail without exposing secrets
ExpirationSet TTL (90 days default), require renewalexpires_at column in DBLimits window of exposure for forgotten keys

Decision Tree

START
├── Need to identify callers + enforce rate limits only?
│   ├── YES → Simple API key (single secret, SHA-256 hashed)
│   └── NO ↓
├── Need to verify request integrity (signed payloads)?
│   ├── YES → Key + Secret pair with HMAC-SHA256 signing
│   └── NO ↓
├── Need delegated user-level access with scopes?
│   ├── YES → OAuth 2.0 (API key for client ID, OAuth for user auth)
│   └── NO ↓
├── Need both client-side (browser) and server-side keys?
│   ├── YES → Publishable + Secret key pair (Stripe model)
│   └── NO ↓
└── DEFAULT → Single secret API key with SHA-256 hashing + prefix

Step-by-Step Guide

1. Design your key format

Choose a prefix scheme that encodes key type + environment. The prefix is the only part you store in plaintext alongside the hash. [src2]

Format: {type}_{environment}_{random_payload}

Examples:
  sk_live_a8f2e9c1b4d7...   (secret key, production)
  sk_test_7e3b1a9d5f2c...   (secret key, sandbox)
  pk_live_c4d8a2f1e6b9...   (publishable key, production)
  pk_test_9b1e7c3d5a8f...   (publishable key, sandbox)

Verify: Your regex ^(sk|pk)_(live|test)_[a-f0-9]{64}$ should match all generated keys.

2. Generate cryptographically secure keys

Use your language's CSPRNG. Never use Math.random() or random.random(). [src6]

const crypto = require('crypto');
const prefix = 'sk_live_';
const payload = crypto.randomBytes(32).toString('hex'); // 64 hex chars
const apiKey = `${prefix}${payload}`;
// Result: sk_live_a8f2e9c1b4d7...  (78 chars total)

Verify: node -e "console.log(crypto.randomBytes(32).toString('hex'))" should produce a different value each run.

3. Hash and store the key

Store only the SHA-256 hash in your database. Return the raw key to the user exactly once. [src1]

const crypto = require('crypto');

function hashApiKey(rawKey) {
  return crypto.createHash('sha256').update(rawKey).digest('hex');
}

// On key creation:
const rawKey = generateApiKey();       // sk_live_a8f2e9...
const keyHash = hashApiKey(rawKey);    // Store this in DB
const prefix = rawKey.slice(0, 12);    // sk_live_a8f2 — store for identification

Verify: SELECT key_hash FROM api_keys WHERE prefix = 'sk_live_a8f2' returns a 64-char hex string, not the raw key.

4. Validate keys on every request

Extract from header, hash, and look up. [src7]

async function validateApiKey(req) {
  const authHeader = req.headers['authorization'];
  if (!authHeader?.startsWith('Bearer ')) return null;

  const rawKey = authHeader.slice(7);
  const keyHash = hashApiKey(rawKey);

  const keyRecord = await db.query(
    'SELECT * FROM api_keys WHERE key_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()',
    [keyHash]
  );
  return keyRecord.rows[0] || null;
}

Verify: Send a request with Authorization: Bearer <valid-key> — should return 200. Invalid key — should return 401.

5. Implement key rotation

Allow two active keys per client. Create the new key, let the client migrate, then revoke the old one. [src3]

-- Rotation: create new key (old stays active)
INSERT INTO api_keys (key_hash, prefix, client_id, scopes, expires_at)
VALUES ($1, $2, $3, $4, NOW() + INTERVAL '90 days');

-- After client confirms migration: revoke old key
UPDATE api_keys SET revoked_at = NOW() WHERE id = $old_key_id;

Verify: Both old and new keys return 200 during the overlap window. After revocation, old key returns 401.

Code Examples

Node.js: Complete API Key Service

// Input:  client_id, scopes array
// Output: { rawKey, prefix, keyHash, expiresAt }

const crypto = require('crypto');

class ApiKeyService {
  generateKey(type = 'sk', env = 'live') {
    const payload = crypto.randomBytes(32).toString('hex');
    const rawKey = `${type}_${env}_${payload}`;
    const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex');
    const prefix = rawKey.slice(0, 12);
    return { rawKey, prefix, keyHash };
  }

  async createKey(clientId, scopes = ['read'], ttlDays = 90) {
    const { rawKey, prefix, keyHash } = this.generateKey();
    const expiresAt = new Date(Date.now() + ttlDays * 86400000);
    await this.db.query(
      `INSERT INTO api_keys (key_hash, prefix, client_id, scopes, expires_at)
       VALUES ($1, $2, $3, $4, $5)`,
      [keyHash, prefix, clientId, scopes, expiresAt]
    );
    return { rawKey, prefix, expiresAt }; // rawKey shown once only
  }

  async validateKey(rawKey) {
    const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex');
    const result = await this.db.query(
      `SELECT client_id, scopes, expires_at FROM api_keys
       WHERE key_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()`,
      [keyHash]
    );
    return result.rows[0] || null;
  }
}

Python: API Key Generation and Validation

# Input:  client_id, scopes list
# Output: dict with raw_key, prefix, key_hash, expires_at

import secrets
import hashlib
from datetime import datetime, timedelta

def generate_api_key(key_type="sk", env="live"):
    """Generate a prefixed API key with 256-bit entropy."""
    payload = secrets.token_hex(32)  # 64 hex chars, 256 bits
    raw_key = f"{key_type}_{env}_{payload}"
    key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
    prefix = raw_key[:12]
    return {"raw_key": raw_key, "prefix": prefix, "key_hash": key_hash}

def validate_api_key(raw_key: str, db_cursor) -> dict | None:
    """Validate an API key by hashing and looking up the digest."""
    key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
    db_cursor.execute(
        "SELECT client_id, scopes, expires_at FROM api_keys "
        "WHERE key_hash = %s AND revoked_at IS NULL AND expires_at > NOW()",
        (key_hash,)
    )
    return db_cursor.fetchone()

Go: API Key Generation

// Input:  keyType string, env string
// Output: ApiKey struct with RawKey, Prefix, KeyHash

package apikeys

import (
    "crypto/rand"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
)

type ApiKey struct {
    RawKey  string
    Prefix  string
    KeyHash string
}

func GenerateApiKey(keyType, env string) (*ApiKey, error) {
    payload := make([]byte, 32) // 256-bit entropy
    if _, err := rand.Read(payload); err != nil {
        return nil, fmt.Errorf("CSPRNG failed: %w", err)
    }
    rawKey := fmt.Sprintf("%s_%s_%s", keyType, env, hex.EncodeToString(payload))
    hash := sha256.Sum256([]byte(rawKey))
    return &ApiKey{
        RawKey:  rawKey,
        Prefix:  rawKey[:12],
        KeyHash: hex.EncodeToString(hash[:]),
    }, nil
}

Anti-Patterns

Wrong: Storing plaintext API keys

// BAD — raw key stored in database
await db.query(
  'INSERT INTO api_keys (api_key, client_id) VALUES ($1, $2)',
  [rawApiKey, clientId]  // rawApiKey in plaintext!
);

Correct: Storing hashed API keys

// GOOD — only the SHA-256 hash is stored
const keyHash = crypto.createHash('sha256').update(rawApiKey).digest('hex');
await db.query(
  'INSERT INTO api_keys (key_hash, prefix, client_id) VALUES ($1, $2, $3)',
  [keyHash, rawApiKey.slice(0, 12), clientId]
);

Wrong: Passing API keys in URL query parameters

GET /api/v1/users?api_key=sk_live_a8f2e9c1b4d7... HTTP/1.1

Correct: Passing API keys in Authorization header

GET /api/v1/users HTTP/1.1
Authorization: Bearer sk_live_a8f2e9c1b4d7...

Wrong: Using Math.random() for key generation

// BAD — predictable, not cryptographically secure
const key = 'sk_live_' + Math.random().toString(36).substring(2);

Correct: Using CSPRNG for key generation

// GOOD — cryptographically secure random bytes
const key = 'sk_live_' + crypto.randomBytes(32).toString('hex');

Wrong: No expiration or rotation policy

-- BAD — keys live forever
CREATE TABLE api_keys (
  id SERIAL PRIMARY KEY,
  key_hash TEXT NOT NULL,
  client_id TEXT NOT NULL
  -- no expires_at, no revoked_at!
);

Correct: Built-in expiration and revocation

-- GOOD — keys have lifecycle management
CREATE TABLE api_keys (
  id SERIAL PRIMARY KEY,
  key_hash TEXT NOT NULL UNIQUE,
  prefix VARCHAR(12) NOT NULL,
  client_id TEXT NOT NULL,
  scopes TEXT[] DEFAULT '{"read"}',
  created_at TIMESTAMPTZ DEFAULT NOW(),
  expires_at TIMESTAMPTZ NOT NULL,
  revoked_at TIMESTAMPTZ,
  last_used_at TIMESTAMPTZ
);
CREATE INDEX idx_api_keys_hash ON api_keys (key_hash) WHERE revoked_at IS NULL;

Wrong: Using same keys across environments

# BAD — production key used in staging
STAGING_API_KEY=sk_live_a8f2e9c1...   # This is a PRODUCTION key!

Correct: Environment-specific keys with prefixes

# GOOD — prefix makes environment obvious
STAGING_API_KEY=sk_test_7e3b1a9d...   # Clearly a test key
PROD_API_KEY=sk_live_a8f2e9c1...      # Clearly a production key

Common Pitfalls

Diagnostic Commands

# Check if keys are being sent in URL params (Nginx access log)
grep 'api_key=' /var/log/nginx/access.log | head -20

# Count active (non-expired, non-revoked) keys per client
psql -c "SELECT client_id, COUNT(*) FROM api_keys WHERE revoked_at IS NULL AND expires_at > NOW() GROUP BY client_id ORDER BY count DESC;"

# Find keys unused for 90+ days (revocation candidates)
psql -c "SELECT prefix, client_id, last_used_at FROM api_keys WHERE last_used_at < NOW() - INTERVAL '90 days' AND revoked_at IS NULL;"

# Verify a key hash matches (debugging)
echo -n "sk_live_yourkeyhere" | sha256sum

# Check for leaked keys in git history
git log -p --all -S 'sk_live_' -- '*.js' '*.py' '*.env'

# AWS: list access keys and their age
aws iam list-access-keys --user-name <username>

Version History & Compatibility

Standard/PlatformStatusKey ChangesNotes
OWASP API Security Top 10 (2023)CurrentAPI2:2023 Broken AuthenticationAPI keys for identification, not sole authentication
Stripe Key Design (2011-present)Industry standardPrefixed keys, restricted keys (2020)Most widely copied key format
AWS IAM Access KeysCurrentRotation every 90 days recommendedAWS recommends STS temporary credentials over long-lived keys
Google Cloud API KeysCurrentApplication + API restrictions (2019)HTTP referrer, IP, and API-level scoping
RFC 6750 (Bearer Tokens)CurrentDefines Authorization: Bearer headerStandard transport mechanism for API keys

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Identifying API consumers for rate limiting and analyticsAuthenticating end users with sessionsOAuth 2.0 + session tokens
Simple server-to-server authentication (trusted parties)Delegating limited access on behalf of a userOAuth 2.0 authorization code flow
Public-facing APIs needing per-client throttlingRequest integrity/tampering protection requiredHMAC-SHA256 signed requests
Internal microservice identification (low sensitivity)Highly sensitive financial or health data APIsMutual TLS (mTLS) or OAuth 2.0 client credentials
Rate limiting and usage tracking per consumerFine-grained, per-resource, per-action authorizationRBAC/ABAC with JWT claims

Important Caveats

Related Units