Loading content...
API keys are simple, static credentials used to authenticate API requests. Despite the rise of OAuth2 and JWT-based authentication, API keys remain widely used due to their simplicity—particularly for server-to-server communication, developer APIs, and public API access.
At their core, API keys are straightforward: the client includes a secret string with each request, and the gateway validates this string against a known set of valid keys. This simplicity is both their strength and their limitation.
Where API Keys Excel:
Where API Keys Fall Short:
This page covers the complete lifecycle of API key management at the gateway: generation, transmission, validation, rotation, and revocation—with production-grade security considerations throughout.
API keys identify applications or accounts, not individual users. They cannot be used for user-facing authentication. If you need to know which user is making a request, you need OAuth2/JWT or session-based authentication in addition to (or instead of) API keys.
The security of an API key system begins with how keys are generated. Poorly designed keys can be guessed, predicted, or enumerated—completely negating their purpose.
A well-designed API key typically consists of:
For example:
sk_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6 (Stripe-style)
ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (GitHub-style)
AKIAIOSFODNN7EXAMPLE (AWS-style, for reference)
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
import crypto from "crypto"; interface ApiKeyConfig { prefix: string; // e.g., "sk_live" or "sk_test" secretLength: number; // Bytes of entropy (32 = 256 bits, recommended) includeChecksum: boolean;} interface GeneratedApiKey { fullKey: string; // Complete key to give to user (shown once) keyHash: string; // Store this in database (never store full key) prefix: string; // For identification keyId: string; // Short ID for logging/reference createdAt: Date;} function generateApiKey(config: ApiKeyConfig): GeneratedApiKey { // Generate cryptographically secure random bytes const randomBytes = crypto.randomBytes(config.secretLength); // Encode as URL-safe base64 (no +, /, or =) const secret = randomBytes .toString("base64") .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=/g, ""); // Construct full key const fullKey = `${config.prefix}_${secret}`; // Generate hash for storage (using SHA-256) const keyHash = crypto .createHash("sha256") .update(fullKey) .digest("hex"); // Create short ID for logging (first 8 chars of hash) const keyId = keyHash.substring(0, 8); return { fullKey, keyHash, prefix: config.prefix, keyId, createdAt: new Date(), };} // Production configurationconst KEY_CONFIGS = { live: { prefix: "sk_live", secretLength: 32, // 256 bits of entropy includeChecksum: false, }, test: { prefix: "sk_test", secretLength: 32, includeChecksum: false, }, publishable: { prefix: "pk_live", secretLength: 24, // Less entropy needed (public key) includeChecksum: false, },}; // Usage example:// const key = generateApiKey(KEY_CONFIGS.live);// // Response to user (ONLY ONCE):// { apiKey: "sk_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2" }//// Store in database:// { keyHash: "abc123...", prefix: "sk_live", keyId: "abc12345", ... }Store only the hash of the API key, never the key itself. If your database is compromised, leaked hashes cannot be reversed. Validate incoming keys by hashing them and comparing to stored hashes.
The security of an API key depends entirely on its unpredictability:
| Key Length (bytes) | Bits of Entropy | Brute Force Attempts | Security Level |
|---|---|---|---|
| 16 | 128 bits | 3.4 × 10³⁸ | Minimum acceptable |
| 24 | 192 bits | 6.3 × 10⁵⁷ | Good |
| 32 | 256 bits | 1.2 × 10⁷⁷ | Excellent (recommended) |
| 64 | 512 bits | 1.3 × 10¹⁵⁴ | Overkill for most uses |
Rule of thumb: Use 32 bytes (256 bits) of cryptographic random data. This is computationally infeasible to brute force with any conceivable technology.
How clients transmit API keys affects both security and developer experience. The gateway must support appropriate transmission methods while rejecting insecure practices.
The most secure and standard approach uses the Authorization header with a custom scheme or Bearer token format:
12345678910111213141516
# Option A: Bearer token (if API key is the sole auth)GET /api/v1/users HTTP/1.1Host: api.example.comAuthorization: Bearer sk_live_a1b2c3d4e5f6g7h8 # Option B: Custom scheme (clearer intent)GET /api/v1/users HTTP/1.1Host: api.example.comAuthorization: ApiKey sk_live_a1b2c3d4e5f6g7h8 # Option C: Basic auth (username:password format, common for services)# Username = API key, Password = empty or additional secretGET /api/v1/users HTTP/1.1Host: api.example.comAuthorization: Basic c2tfbGl2ZV9hMWIyYzNkNGU1ZjZnN2g4Og==# Base64 of "sk_live_a1b2c3d4e5f6g7h8:"Some APIs use a dedicated header for API keys:
12345678
GET /api/v1/users HTTP/1.1Host: api.example.comX-API-Key: sk_live_a1b2c3d4e5f6g7h8 # Or company-specific naming:GET /api/v1/users HTTP/1.1Host: api.example.comX-Acme-Api-Key: sk_live_a1b2c3d4e5f6g7h8Passing API keys in query parameters is strongly discouraged but sometimes supported for backwards compatibility:
123
# DISCOURAGED - Keys appear in logs, browser history, referrer headersGET /api/v1/users?api_key=sk_live_a1b2c3d4e5f6g7h8 HTTP/1.1Host: api.example.comAPI keys in query parameters get logged everywhere: server access logs, CDN logs, browser history, referrer headers, and analytics tools. A single log exposure compromises the key. Always prefer headers.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
interface KeyExtractionResult { key: string | null; method: "authorization" | "custom_header" | "query" | null; warning: string | null;} function extractApiKey(request: Request): KeyExtractionResult { // Priority 1: Authorization header const authHeader = request.headers.get("authorization"); if (authHeader) { // Bearer token if (authHeader.toLowerCase().startsWith("bearer ")) { return { key: authHeader.substring(7).trim(), method: "authorization", warning: null, }; } // ApiKey scheme if (authHeader.toLowerCase().startsWith("apikey ")) { return { key: authHeader.substring(7).trim(), method: "authorization", warning: null, }; } // Basic auth if (authHeader.toLowerCase().startsWith("basic ")) { const decoded = Buffer.from(authHeader.substring(6), "base64").toString(); const [username, password] = decoded.split(":"); return { key: username, // API key as username method: "authorization", warning: null, }; } } // Priority 2: X-API-Key header const apiKeyHeader = request.headers.get("x-api-key"); if (apiKeyHeader) { return { key: apiKeyHeader.trim(), method: "custom_header", warning: null, }; } // Priority 3: Query parameter (discouraged, with warning) const url = new URL(request.url); const queryKey = url.searchParams.get("api_key") || url.searchParams.get("apiKey") || url.searchParams.get("key"); if (queryKey) { return { key: queryKey, method: "query", warning: "API key passed in query parameter - consider using headers", }; } return { key: null, method: null, warning: null };}API key validation at the gateway must be fast (critical path), secure (constant-time comparison), and resilient (handle high volumes). Let's examine production-grade validation architecture.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
interface ApiKeyMetadata { keyId: string; accountId: string; organizationId: string; environment: "live" | "test"; scopes: string[]; rateLimits: { requestsPerMinute: number; requestsPerDay: number; }; status: "active" | "revoked" | "expired"; createdAt: Date; expiresAt: Date | null; lastUsed: Date | null;} class ApiKeyValidator { private cache: LRUCache<string, ApiKeyMetadata | null>; constructor( private database: KeyDatabase, private redis: Redis, config: ValidatorConfig ) { // Local LRU cache for hot keys this.cache = new LRUCache({ max: config.localCacheSize || 10000, ttl: config.localCacheTtl || 60000, // 1 minute }); } async validate(apiKey: string): Promise<ApiKeyMetadata | null> { // Step 1: Hash the key for lookup (never query with plain key) const keyHash = this.hashKey(apiKey); // Step 2: Check local LRU cache const localCached = this.cache.get(keyHash); if (localCached !== undefined) { // Cache hit (even if null = key doesn't exist) return localCached; } // Step 3: Check distributed cache (Redis) const redisCached = await this.redis.get(`apikey:${keyHash}`); if (redisCached) { const metadata = JSON.parse(redisCached) as ApiKeyMetadata; this.cache.set(keyHash, metadata); return metadata; } // Step 4: Query database const metadata = await this.database.getKeyByHash(keyHash); if (metadata) { // Cache for future requests await this.redis.setex( `apikey:${keyHash}`, 300, // 5 minute TTL JSON.stringify(metadata) ); this.cache.set(keyHash, metadata); // Async: Update last used timestamp this.updateLastUsed(keyHash).catch(console.error); } else { // Cache negative result briefly to prevent enumeration attacks this.cache.set(keyHash, null); await this.redis.setex(`apikey:${keyHash}`, 60, "null"); } return metadata; } private hashKey(apiKey: string): string { // Use SHA-256 for consistent hashing return crypto .createHash("sha256") .update(apiKey) .digest("hex"); } private async updateLastUsed(keyHash: string): Promise<void> { // Batch updates to reduce database load // Only update if not updated in the last hour const lastUpdate = await this.redis.get(`apikey:lastupdate:${keyHash}`); if (!lastUpdate) { await this.database.updateLastUsed(keyHash, new Date()); await this.redis.setex(`apikey:lastupdate:${keyHash}`, 3600, "1"); } }}When comparing API keys, use constant-time comparison to prevent timing attacks:
12345678910111213141516171819202122232425262728293031323334353637
import crypto from "crypto"; /** * Constant-time string comparison to prevent timing attacks. * * Timing attacks work by measuring how long comparison takes. * Standard string comparison (===) returns false as soon as a * mismatch is found, revealing information about the secret. */function constantTimeCompare(a: string, b: string): boolean { // Convert to buffers for crypto.timingSafeEqual const bufferA = Buffer.from(a); const bufferB = Buffer.from(b); // Must be same length for timingSafeEqual if (bufferA.length !== bufferB.length) { // Still compare against a dummy to maintain constant time const dummy = Buffer.alloc(bufferA.length); crypto.timingSafeEqual(bufferA, dummy); return false; } return crypto.timingSafeEqual(bufferA, bufferB);} // For hash comparison (preferred - compare hashes, not keys)function validateKeyByHash( providedKey: string, storedHash: string): boolean { const providedHash = crypto .createHash("sha256") .update(providedKey) .digest("hex"); return constantTimeCompare(providedHash, storedHash);}By hashing the provided key and comparing hashes, you never store or compare the actual key. Even if an attacker could exploit timing differences, they would only learn about the hash, not the key itself. Combined with constant-time comparison, this provides defense in depth.
API keys are commonly associated with rate limits and usage quotas. The gateway must enforce these limits efficiently while providing clear feedback to developers.
Rate limits can apply at multiple levels:
| Dimension | Description | Example |
|---|---|---|
| Per Key | Each API key has its own limits | 1000 requests/minute per key |
| Per Account | All keys for an account share limits | 100,000 requests/day per account |
| Per Plan | Limits vary by subscription tier | Free: 100/day, Pro: 10,000/day |
| Per Endpoint | Different limits for different operations | GET: 1000/min, POST: 100/min |
| Global | System-wide limits for protection | No single IP > 10,000/sec |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
interface RateLimitConfig { limits: RateLimitRule[]; headers: { includeRemaining: boolean; includeReset: boolean; };} interface RateLimitRule { dimension: "key" | "account" | "ip" | "endpoint"; period: "second" | "minute" | "hour" | "day"; limit: number; burstLimit?: number; // Briefly exceed base limit} interface RateLimitResult { allowed: boolean; remaining: number; resetAt: Date; retryAfter?: number; // Seconds until retry limitExceeded?: { dimension: string; limit: number; current: number; };} class RateLimiter { constructor(private redis: Redis) {} async checkRateLimit( keyMetadata: ApiKeyMetadata, request: Request ): Promise<RateLimitResult> { const now = Date.now(); const results: RateLimitResult[] = []; // Check all applicable limits const limits = keyMetadata.rateLimits; // Per-key per-minute limit results.push(await this.checkLimit({ key: `ratelimit:key:${keyMetadata.keyId}:minute`, limit: limits.requestsPerMinute, windowMs: 60000, now, })); // Per-account daily limit results.push(await this.checkLimit({ key: `ratelimit:account:${keyMetadata.accountId}:day`, limit: limits.requestsPerDay, windowMs: 86400000, now, })); // Per-endpoint limit (if configured) const endpoint = this.extractEndpoint(request); const endpointLimit = this.getEndpointLimit(endpoint); if (endpointLimit) { results.push(await this.checkLimit({ key: `ratelimit:key:${keyMetadata.keyId}:endpoint:${endpoint}:minute`, limit: endpointLimit, windowMs: 60000, now, })); } // Return the most restrictive result const failedResult = results.find(r => !r.allowed); if (failedResult) { return failedResult; } // Return combined result (minimum remaining) const minRemaining = Math.min(...results.map(r => r.remaining)); const earliestReset = new Date(Math.min(...results.map(r => r.resetAt.getTime()))); return { allowed: true, remaining: minRemaining, resetAt: earliestReset, }; } private async checkLimit(params: { key: string; limit: number; windowMs: number; now: number; }): Promise<RateLimitResult> { const { key, limit, windowMs, now } = params; const windowStart = now - windowMs; // Use sliding window with Redis sorted set const pipeline = this.redis.pipeline(); // Remove old entries pipeline.zremrangebyscore(key, 0, windowStart); // Count current entries pipeline.zcard(key); // Add new entry pipeline.zadd(key, now.toString(), `${now}:${crypto.randomUUID()}`); // Set expiry pipeline.pexpire(key, windowMs); const results = await pipeline.exec(); const currentCount = (results?.[1]?.[1] as number) || 0; if (currentCount >= limit) { // Get oldest entry to calculate reset time const oldest = await this.redis.zrange(key, 0, 0, "WITHSCORES"); const oldestTimestamp = parseInt(oldest[1] || String(now)); const resetAt = new Date(oldestTimestamp + windowMs); return { allowed: false, remaining: 0, resetAt, retryAfter: Math.ceil((resetAt.getTime() - now) / 1000), limitExceeded: { dimension: key, limit, current: currentCount, }, }; } return { allowed: true, remaining: limit - currentCount - 1, // -1 for this request resetAt: new Date(now + windowMs), }; }}Communicate limits clearly to developers through standard headers:
123456789101112131415161718192021222324
HTTP/1.1 200 OKX-RateLimit-Limit: 1000X-RateLimit-Remaining: 842X-RateLimit-Reset: 1704067260 # When limit exceeded:HTTP/1.1 429 Too Many RequestsX-RateLimit-Limit: 1000X-RateLimit-Remaining: 0X-RateLimit-Reset: 1704067260Retry-After: 45Content-Type: application/json { "error": { "code": "rate_limit_exceeded", "message": "Rate limit exceeded. Please retry after 45 seconds.", "details": { "limit": 1000, "window": "minute", "reset_at": "2024-01-01T00:01:00Z" } }}API keys must be managed throughout their lifecycle: creation, distribution, rotation, and eventual revocation. The gateway must support these operations securely.
┌─────────┐ ┌─────────┐ ┌─────────────┐ ┌─────────┐
│ Created │────▶│ Active │────▶│ Deprecated │────▶│ Revoked │
└─────────┘ └─────────┘ └─────────────┘ └─────────┘
│ │
│ │ grace
│ │ period
└────────▶ ◀───────┘
Expired
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
interface KeyValidationResult { valid: boolean; status: "active" | "deprecated" | "revoked" | "expired" | "not_found"; metadata?: ApiKeyMetadata; warning?: string; headers?: Record<string, string>;} function validateKeyStatus(metadata: ApiKeyMetadata | null): KeyValidationResult { if (!metadata) { return { valid: false, status: "not_found", }; } const now = new Date(); // Revoked keys are immediately rejected if (metadata.status === "revoked") { return { valid: false, status: "revoked", headers: { "X-Key-Status": "revoked", }, }; } // Check expiration if (metadata.expiresAt && metadata.expiresAt < now) { return { valid: false, status: "expired", headers: { "X-Key-Status": "expired", "X-Key-Expired-At": metadata.expiresAt.toISOString(), }, }; } // Check if approaching expiration (deprecation warning) if (metadata.expiresAt) { const daysUntilExpiry = (metadata.expiresAt.getTime() - now.getTime()) / 86400000; if (daysUntilExpiry <= 30) { return { valid: true, status: "deprecated", metadata, warning: `API key expires in ${Math.ceil(daysUntilExpiry)} days`, headers: { "X-Key-Status": "deprecated", "X-Key-Expires-At": metadata.expiresAt.toISOString(), "Deprecation": metadata.expiresAt.toISOString(), }, }; } } // Active key return { valid: true, status: "active", metadata, headers: { "X-Key-Status": "active", }, };} // Gateway middleware applying status validationasync function apiKeyMiddleware(request: Request): Promise<Response | null> { const { key, method, warning: extractionWarning } = extractApiKey(request); if (!key) { return new Response(JSON.stringify({ error: { code: "missing_api_key", message: "API key required", }, }), { status: 401, headers: { "Content-Type": "application/json" }, }); } const metadata = await validator.validate(key); const validation = validateKeyStatus(metadata); if (!validation.valid) { return new Response(JSON.stringify({ error: { code: `api_key_${validation.status}`, message: getStatusMessage(validation.status), }, }), { status: 401, headers: { "Content-Type": "application/json", ...validation.headers, }, }); } // Key is valid - add headers and continue if (validation.headers) { for (const [key, value] of Object.entries(validation.headers)) { request.headers.set(key, value); } } // Log deprecation warnings if (validation.warning) { console.warn(`API key deprecation: ${validation.warning}`, { keyId: metadata?.keyId, accountId: metadata?.accountId, }); } return null; // Continue to next middleware}Secure key rotation allows updating keys without service disruption:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
async function rotateApiKey( accountId: string, existingKeyId: string, options: RotationOptions = {}): Promise<KeyRotationResult> { const { deprecationPeriodDays = 30, notifyWebhook = true, } = options; // Step 1: Verify existing key belongs to account const existingKey = await database.getKey(existingKeyId); if (!existingKey || existingKey.accountId !== accountId) { throw new Error("Key not found or access denied"); } // Step 2: Generate new key const newKey = generateApiKey({ prefix: existingKey.environment === "live" ? "sk_live" : "sk_test", secretLength: 32, includeChecksum: false, }); // Step 3: Store new key await database.createKey({ keyHash: newKey.keyHash, keyId: newKey.keyId, accountId: accountId, environment: existingKey.environment, scopes: existingKey.scopes, rateLimits: existingKey.rateLimits, status: "active", createdAt: new Date(), expiresAt: null, rotatedFrom: existingKeyId, }); // Step 4: Deprecate old key const deprecationDate = new Date( Date.now() + (deprecationPeriodDays * 86400000) ); await database.updateKey(existingKeyId, { status: "deprecated", expiresAt: deprecationDate, }); // Step 5: Invalidate caches await redis.del(`apikey:${existingKey.keyHash}`); // Step 6: Notify if (notifyWebhook) { await notificationService.sendKeyRotation({ accountId, oldKeyId: existingKeyId, newKeyId: newKey.keyId, deprecationDate, }); } // Audit log await auditLog.record({ action: "api_key.rotated", actor: { type: "account", id: accountId }, resource: { type: "api_key", id: existingKeyId }, metadata: { newKeyId: newKey.keyId, deprecationPeriodDays, }, }); return { newKey: { id: newKey.keyId, fullKey: newKey.fullKey, // Only time full key is exposed createdAt: newKey.createdAt, }, oldKey: { id: existingKeyId, expiresAt: deprecationDate, }, };}API key security extends beyond generation and validation. Consider these production security measures:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
interface AnomalyDetectionConfig { // Geographic anomaly geo: { enabled: boolean; baselineCountries: string[]; alertOnNew: boolean; }; // Usage pattern anomaly usage: { enabled: boolean; baselineRequestsPerHour: number; anomalyThreshold: number; // Multiplier (e.g., 10x normal) }; // Time-based anomaly temporal: { enabled: boolean; baselineHours: number[]; // Expected hours (0-23) alertOnOffHours: boolean; };} class KeyAnomalyDetector { async checkForAnomalies( keyMetadata: ApiKeyMetadata, request: Request ): Promise<AnomalyAlert[]> { const alerts: AnomalyAlert[] = []; const keyUsage = await this.getKeyUsageProfile(keyMetadata.keyId); // Geographic anomaly const requestCountry = this.getCountryFromIP(request); if (requestCountry && !keyUsage.countries.includes(requestCountry)) { alerts.push({ type: "geographic_anomaly", severity: "medium", message: `Request from new country: ${requestCountry}`, metadata: { newCountry: requestCountry, knownCountries: keyUsage.countries, }, }); } // Usage spike detection const recentRate = await this.getRecentRequestRate(keyMetadata.keyId); if (recentRate > keyUsage.averageRequestsPerHour * 10) { alerts.push({ type: "usage_spike", severity: "high", message: `Request rate spike: ${recentRate}/hour vs ${keyUsage.averageRequestsPerHour}/hour average`, metadata: { currentRate: recentRate, averageRate: keyUsage.averageRequestsPerHour, }, }); } // Off-hours activity const hour = new Date().getUTCHours(); if (!keyUsage.activeHours.includes(hour)) { alerts.push({ type: "temporal_anomaly", severity: "low", message: `Request outside normal hours (UTC ${hour}:00)`, metadata: { currentHour: hour, normalHours: keyUsage.activeHours, }, }); } // Log alerts for (const alert of alerts) { await this.recordAlert(keyMetadata.keyId, alert); } return alerts; } async handleSuspiciousActivity( keyMetadata: ApiKeyMetadata, alerts: AnomalyAlert[] ): Promise<SecurityAction> { const severity = Math.max(...alerts.map(a => a.severity === "critical" ? 4 : a.severity === "high" ? 3 : a.severity === "medium" ? 2 : 1 )); if (severity >= 4) { // Critical: Auto-revoke and notify await this.revokeKey(keyMetadata.keyId, "automatic_security"); await this.notifySecurityTeam(keyMetadata, alerts); return { action: "revoke", reason: "critical_security_alert" }; } if (severity >= 3) { // High: Notify and add friction await this.notifyKeyOwner(keyMetadata, alerts); return { action: "throttle", reason: "high_security_alert" }; } // Medium/Low: Log only return { action: "allow", reason: "below_action_threshold" }; }}Monitor for API keys appearing in public repositories, logs, or other exposed locations:
sk_live_* with sk_live_[REDACTED]).sk_live_) that are easy to detect and hard to confuse with other data.123456789101112131415161718192021222324252627282930
// Patterns for API keys to redact from logsconst KEY_PATTERNS = [ /sk_live_[a-zA-Z0-9_-]{20,}/g, /sk_test_[a-zA-Z0-9_-]{20,}/g, /pk_live_[a-zA-Z0-9_-]{20,}/g, /Bearer [a-zA-Z0-9._-]{40,}/g,]; function redactSensitiveData(logEntry: string): string { let redacted = logEntry; for (const pattern of KEY_PATTERNS) { redacted = redacted.replace(pattern, (match) => { const prefix = match.substring(0, 8); return `${prefix}[REDACTED]`; }); } return redacted;} // Custom logger that redacts before outputconst secureLogger = { info: (message: string, ...args: any[]) => { console.log(redactSensitiveData(message), ...args.map(arg => typeof arg === "string" ? redactSensitiveData(arg) : arg )); }, // ... other log levels};API keys excel in specific scenarios but aren't always the right choice. Understanding the trade-offs helps you select the appropriate authentication method.
| Factor | API Keys | OAuth2/JWT |
|---|---|---|
| Complexity | Simple (single header) | Complex (token flows, refresh) |
| Identity | Application/Account level | User or Application level |
| Token Lifetime | Long-lived (months/years) | Short-lived (minutes/hours) |
| Revocation | Immediate (database lookup) | Delayed (token expiry) |
| Stateless Validation | No (requires lookup) | Yes (cryptographic) |
| Mobile/Browser Safe | No (easily extracted) | Yes (with proper flows) |
| Delegated Access | No | Yes (scopes, user consent) |
| Use Case | Server-to-server, developers | End-user facing, complex auth |
Many production systems combine both: API keys for identifying the application/developer (who gets billed, which rate limits apply) plus JWT/OAuth2 for identifying the end-user (who has permission to access what). This provides both application-level and user-level security.
API keys remain a valuable authentication mechanism when used appropriately. Their simplicity is both their greatest strength and their limitation.
With API keys covered, the final page explores mTLS for Service-to-Service Authentication—the strongest form of mutual authentication using client certificates, essential for zero-trust architectures and highly sensitive internal communications.