crypto.randomBytes(32).toString('hex') (Node.js) or secrets.token_hex(32) (Python)sk_live_, pk_test_) — enables quick classification, leak scanning in git repos, and environment separation. [src2]Authorization: Bearer <key>) — never in URL query parameters. [src7]| Aspect | Best Practice | Example | Rationale |
|---|---|---|---|
| Generation | Use CSPRNG, 32+ bytes (256 bits entropy) | crypto.randomBytes(32) | Prevents brute-force and prediction attacks |
| Format | Prefix + base62/hex payload | sk_live_a1b2c3... (Stripe style) | Prefix enables leak detection and environment routing |
| Storage | Hash with SHA-256, store only the digest | SHA-256(key) in DB, raw key shown once | Compromised DB does not expose usable keys |
| Transmission | Authorization: Bearer <key> header over HTTPS | Never in URL query params | URLs are logged in server logs, proxies, and browser history |
| Rotation | Dual-key overlap window (7-30 days) | Create new key, migrate, revoke old | Zero-downtime rotation without breaking consumers |
| Revocation | Immediate hard-delete or soft-delete with TTL | Delete hash from DB, return 401 on next use | Limits blast radius of compromised keys |
| Scoping | Per-key permission set (read/write/admin) | Google Cloud API restrictions | Least privilege — limits damage from a leaked key |
| Rate Limiting | Per-key rate limit, not just per-IP | 100 req/min per key, 429 on exceed | Prevents abuse and identifies heavy consumers |
| Logging | Log key prefix + action, never the full key | sk_live_a1b2... accessed /api/v1/users | Audit trail without exposing secrets |
| Expiration | Set TTL (90 days default), require renewal | expires_at column in DB | Limits window of exposure for forgotten keys |
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
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.
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.
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.
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.
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.
// 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;
}
}
# 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()
// 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
}
// 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!
);
// 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]
);
GET /api/v1/users?api_key=sk_live_a8f2e9c1b4d7... HTTP/1.1
GET /api/v1/users HTTP/1.1
Authorization: Bearer sk_live_a8f2e9c1b4d7...
// BAD — predictable, not cryptographically secure
const key = 'sk_live_' + Math.random().toString(36).substring(2);
// GOOD — cryptographically secure random bytes
const key = 'sk_live_' + crypto.randomBytes(32).toString('hex');
-- 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!
);
-- 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;
# BAD — production key used in staging
STAGING_API_KEY=sk_live_a8f2e9c1... # This is a PRODUCTION key!
# 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
Authorization header. [src7]429 Too Many Requests with per-key counters (Redis INCR + EXPIRE). [src7]# 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>
| Standard/Platform | Status | Key Changes | Notes |
|---|---|---|---|
| OWASP API Security Top 10 (2023) | Current | API2:2023 Broken Authentication | API keys for identification, not sole authentication |
| Stripe Key Design (2011-present) | Industry standard | Prefixed keys, restricted keys (2020) | Most widely copied key format |
| AWS IAM Access Keys | Current | Rotation every 90 days recommended | AWS recommends STS temporary credentials over long-lived keys |
| Google Cloud API Keys | Current | Application + API restrictions (2019) | HTTP referrer, IP, and API-level scoping |
| RFC 6750 (Bearer Tokens) | Current | Defines Authorization: Bearer header | Standard transport mechanism for API keys |
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Identifying API consumers for rate limiting and analytics | Authenticating end users with sessions | OAuth 2.0 + session tokens |
| Simple server-to-server authentication (trusted parties) | Delegating limited access on behalf of a user | OAuth 2.0 authorization code flow |
| Public-facing APIs needing per-client throttling | Request integrity/tampering protection required | HMAC-SHA256 signed requests |
| Internal microservice identification (low sensitivity) | Highly sensitive financial or health data APIs | Mutual TLS (mTLS) or OAuth 2.0 client credentials |
| Rate limiting and usage tracking per consumer | Fine-grained, per-resource, per-action authorization | RBAC/ABAC with JWT claims |