Deep Dive: API Keys & HMAC Signatures
What is API Key & HMAC Authentication?
API key authentication is the simplest form of service-to-service authentication: the calling service includes a pre-shared secret identifier in the request, and the receiving service looks it up to verify the caller’s identity. In its basic form, the key is sent directly in an HTTP header (X-API-Key: sk_live_abc123). It’s easy to implement and universally understood.
HMAC (Hash-based Message Authentication Code) extends this concept significantly. Instead of transmitting the key itself, the caller uses the key as input to a keyed hash function (typically HMAC-SHA256) applied to the request contents. The server recomputes the HMAC with its copy of the key and verifies they match. This means:
- The key is never transmitted — an eavesdropper sees the signature but cannot recover the key from it
- Request integrity is guaranteed — modifying any part of the signed request invalidates the signature
- Replay attacks are limited — including a timestamp and nonce in the signed content constrains the replay window
This is the same approach used by AWS Signature Version 4, which signs every API call to AWS services. If it’s good enough for AWS’s billing-critical infrastructure, it’s a solid choice for internal services.
Core Principles
- Pre-shared secret: Both caller and callee share a secret key established out-of-band.
- Signed requests: The HMAC signature binds the key to specific request contents, preventing replay and tampering.
- One-way authentication: The client proves identity to the server, but the server’s identity is not cryptographically verified by default (pair with TLS for server verification).
- Stateless verification: The server verifies by recomputing the HMAC — no database lookup needed for verification, only for key lookup.
- Key rotation: Keys must be explicitly rotated; there is no built-in expiry.
Authentication Flow
sequenceDiagram
participant C as Calling Service
participant S as Receiving Service
participant KS as Key Store (Redis/DB)
Note over C: Has API key ID + secret
C->>C: Build canonical request string
C->>C: HMAC-SHA256(canonical_request, secret_key)
C->>S: HTTP Request + X-API-Key-Id + X-Signature + X-Timestamp
S->>KS: Lookup secret by key ID
KS-->>S: Secret key
S->>S: Recompute HMAC-SHA256(canonical_request, secret_key)
S->>S: Compare signatures (constant-time)
S->>S: Verify timestamp within tolerance (±5min)
S-->>C: 200 OK or 401 Unauthorized
Part 1: API Key Design
Key Format and Structure
A well-designed API key encodes metadata to aid debugging and revocation without database lookups:
// key format: {prefix}_{environment}_{random_bytes}
// Example: sk_live_a7f3b9c2d1e4f6a8b0c3d5e7f9a1b2c4d6e8f0a2b4c6d8e0f2a4b6c8d0e2f4a6
interface ApiKeyComponents {
prefix: string; // 'sk' (secret key) or 'pk' (public key)
environment: string; // 'live' or 'test'
secret: string; // 32 bytes of cryptographically random data (hex)
}
// The "key ID" is the prefix + environment + first 8 chars of secret
// The full key includes the secret portion — only stored hashed
// KeyID: sk_live_a7f3b9c2 (safe to log)
// Full: sk_live_a7f3b9c2d1e4f6a8b0c3d5e7f9a1b2c4d6e8f0a2b4c6d8e0f2a4b6c8d0e2f4a6 (secret)// key format: {prefix}_{environment}_{random_bytes}
// Example: sk_live_a7f3b9c2d1e4f6a8b0c3d5e7f9a1b2c4d6e8f0a2b4c6d8e0f2a4b6c8d0e2f4a6
public record ApiKeyComponents(
String prefix, // "sk" (secret key) or "pk" (public key)
String environment, // "live" or "test"
String secret // 32 bytes of cryptographically random data (hex)
) {}
// The "key ID" is the prefix + environment + first 8 chars of secret
// The full key includes the secret portion — only stored hashed
// KeyID: sk_live_a7f3b9c2 (safe to log)
// Full: sk_live_a7f3b9c2d1e4f6a8b0c3d5e7f9a1b2c4d6e8f0a2b4c6d8e0f2a4b6c8d0e2f4a6 (secret)# key format: {prefix}_{environment}_{random_bytes}
# Example: sk_live_a7f3b9c2d1e4f6a8b0c3d5e7f9a1b2c4d6e8f0a2b4c6d8e0f2a4b6c8d0e2f4a6
from dataclasses import dataclass
@dataclass
class ApiKeyComponents:
prefix: str # 'sk' (secret key) or 'pk' (public key)
environment: str # 'live' or 'test'
secret: str # 32 bytes of cryptographically random data (hex)
# The "key ID" is the prefix + environment + first 8 chars of secret
# The full key includes the secret portion — only stored hashed
# KeyID: sk_live_a7f3b9c2 (safe to log)
# Full: sk_live_a7f3b9c2d1e4f6a8b0c3d5e7f9a1b2c4d6e8f0a2b4c6d8e0f2a4b6c8d0e2f4a6 (secret)// key format: {prefix}_{environment}_{random_bytes}
// Example: sk_live_a7f3b9c2d1e4f6a8b0c3d5e7f9a1b2c4d6e8f0a2b4c6d8e0f2a4b6c8d0e2f4a6
public record ApiKeyComponents(
string Prefix, // "sk" (secret key) or "pk" (public key)
string Environment, // "live" or "test"
string Secret // 32 bytes of cryptographically random data (hex)
);
// The "key ID" is the prefix + environment + first 8 chars of secret
// The full key includes the secret portion — only stored hashed
// KeyID: sk_live_a7f3b9c2 (safe to log)
// Full: sk_live_a7f3b9c2d1e4f6a8b0c3d5e7f9a1b2c4d6e8f0a2b4c6d8e0f2a4b6c8d0e2f4a6 (secret)This approach mirrors Stripe’s key design: the key ID (sk_live_a7f3b9c2) is safe to log and reference in support tickets, while the full key is treated as a password.
Key Generation
import crypto from 'crypto';
import { db } from './database';
import bcrypt from 'bcrypt';
// ============================================================================
// API Key Generation
// ============================================================================
interface ApiKey {
id: string; // Public identifier (stored in plain text)
fullKey: string; // Shown to user ONCE at creation — never stored plain
prefix: string;
environment: 'live' | 'test';
serviceId: string;
permissions: string[];
createdAt: Date;
lastUsedAt?: Date;
expiresAt?: Date;
revokedAt?: Date;
}
async function generateApiKey(
serviceId: string,
environment: 'live' | 'test',
permissions: string[]
): Promise<{ keyId: string; fullKey: string }> {
// Generate cryptographically random secret (32 bytes = 64 hex chars)
const secretBytes = crypto.randomBytes(32);
const secretHex = secretBytes.toString('hex');
// Build the key components
const prefix = 'sk';
const keyId = `${prefix}_${environment}_${secretHex.slice(0, 8)}`;
const fullKey = `${prefix}_${environment}_${secretHex}`;
// Hash the full key for storage (bcrypt — intentionally slow)
// We store the hash, not the key itself
const keyHash = await bcrypt.hash(fullKey, 12);
// Store in database
await db.apiKeys.insert({
id: keyId,
hash: keyHash,
serviceId,
environment,
permissions,
createdAt: new Date(),
});
// Return full key to caller — shown only once
return { keyId, fullKey };
}
// ============================================================================
// API Key Verification
// ============================================================================
async function verifyApiKey(
submittedKey: string
): Promise<{ valid: boolean; serviceId?: string; permissions?: string[] }> {
// Extract the key ID from the submitted key
// Format: sk_live_a7f3b9c2... — key ID is first 3 parts
const parts = submittedKey.split('_');
if (parts.length < 3) {
return { valid: false };
}
const keyId = `${parts[0]}_${parts[1]}_${parts[2].slice(0, 8)}`;
// Look up the key record
const keyRecord = await db.apiKeys.findById(keyId);
if (!keyRecord || keyRecord.revokedAt) {
return { valid: false };
}
if (keyRecord.expiresAt && keyRecord.expiresAt < new Date()) {
return { valid: false };
}
// Constant-time comparison via bcrypt
const isValid = await bcrypt.compare(submittedKey, keyRecord.hash);
if (isValid) {
// Update last used timestamp (async, non-blocking)
db.apiKeys.update(keyId, { lastUsedAt: new Date() }).catch(console.error);
}
return {
valid: isValid,
serviceId: isValid ? keyRecord.serviceId : undefined,
permissions: isValid ? keyRecord.permissions : undefined,
};
}import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
// ============================================================================
// API Key Generation
// ============================================================================
public record ApiKey(
String id,
String prefix,
String environment,
String serviceId,
List<String> permissions,
Instant createdAt,
Instant lastUsedAt,
Instant expiresAt,
Instant revokedAt
) {}
public record GenerateApiKeyResult(String keyId, String fullKey) {}
public record VerifyApiKeyResult(boolean valid, String serviceId, List<String> permissions) {}
@Service
public class ApiKeyService {
private final ApiKeyRepository apiKeyRepository;
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(12);
private final SecureRandom secureRandom = new SecureRandom();
public ApiKeyService(ApiKeyRepository apiKeyRepository) {
this.apiKeyRepository = apiKeyRepository;
}
public GenerateApiKeyResult generateApiKey(
String serviceId, String environment, List<String> permissions) {
// Generate cryptographically random secret (32 bytes = 64 hex chars)
byte[] secretBytes = new byte[32];
secureRandom.nextBytes(secretBytes);
String secretHex = HexFormat.of().formatHex(secretBytes);
// Build the key components
String prefix = "sk";
String keyId = prefix + "_" + environment + "_" + secretHex.substring(0, 8);
String fullKey = prefix + "_" + environment + "_" + secretHex;
// Hash the full key for storage (bcrypt — intentionally slow)
String keyHash = passwordEncoder.encode(fullKey);
// Store in database
apiKeyRepository.insert(new ApiKeyRecord(
keyId, keyHash, serviceId, environment, permissions, Instant.now()
));
// Return full key to caller — shown only once
return new GenerateApiKeyResult(keyId, fullKey);
}
// ============================================================================
// API Key Verification
// ============================================================================
public VerifyApiKeyResult verifyApiKey(String submittedKey) {
// Extract the key ID from the submitted key
// Format: sk_live_a7f3b9c2... — key ID is first 3 parts
String[] parts = submittedKey.split("_");
if (parts.length < 3) {
return new VerifyApiKeyResult(false, null, null);
}
String keyId = parts[0] + "_" + parts[1] + "_" + parts[2].substring(0, 8);
// Look up the key record
Optional<ApiKeyRecord> optRecord = apiKeyRepository.findById(keyId);
if (optRecord.isEmpty() || optRecord.get().revokedAt() != null) {
return new VerifyApiKeyResult(false, null, null);
}
ApiKeyRecord keyRecord = optRecord.get();
if (keyRecord.expiresAt() != null && keyRecord.expiresAt().isBefore(Instant.now())) {
return new VerifyApiKeyResult(false, null, null);
}
// Constant-time comparison via bcrypt
boolean isValid = passwordEncoder.matches(submittedKey, keyRecord.hash());
if (isValid) {
// Update last used timestamp (async, non-blocking)
apiKeyRepository.updateLastUsedAsync(keyId, Instant.now());
}
return new VerifyApiKeyResult(
isValid,
isValid ? keyRecord.serviceId() : null,
isValid ? keyRecord.permissions() : null
);
}
}import os
import secrets
import bcrypt
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Optional
from asyncio import create_task
# ============================================================================
# API Key Generation
# ============================================================================
@dataclass
class ApiKey:
id: str
prefix: str
environment: str
service_id: str
permissions: list[str]
created_at: datetime
last_used_at: Optional[datetime] = None
expires_at: Optional[datetime] = None
revoked_at: Optional[datetime] = None
@dataclass
class GenerateApiKeyResult:
key_id: str
full_key: str
@dataclass
class VerifyApiKeyResult:
valid: bool
service_id: Optional[str] = None
permissions: Optional[list[str]] = None
async def generate_api_key(
service_id: str,
environment: str,
permissions: list[str]
) -> GenerateApiKeyResult:
# Generate cryptographically random secret (32 bytes = 64 hex chars)
secret_hex = secrets.token_hex(32)
# Build the key components
prefix = "sk"
key_id = f"{prefix}_{environment}_{secret_hex[:8]}"
full_key = f"{prefix}_{environment}_{secret_hex}"
# Hash the full key for storage (bcrypt — intentionally slow)
# We store the hash, not the key itself
key_hash = bcrypt.hashpw(full_key.encode(), bcrypt.gensalt(rounds=12)).decode()
# Store in database
await db.api_keys.insert({
"id": key_id,
"hash": key_hash,
"service_id": service_id,
"environment": environment,
"permissions": permissions,
"created_at": datetime.now(timezone.utc),
})
# Return full key to caller — shown only once
return GenerateApiKeyResult(key_id=key_id, full_key=full_key)
# ============================================================================
# API Key Verification
# ============================================================================
async def verify_api_key(submitted_key: str) -> VerifyApiKeyResult:
# Extract the key ID from the submitted key
# Format: sk_live_a7f3b9c2... — key ID is first 3 parts
parts = submitted_key.split("_")
if len(parts) < 3:
return VerifyApiKeyResult(valid=False)
key_id = f"{parts[0]}_{parts[1]}_{parts[2][:8]}"
# Look up the key record
key_record = await db.api_keys.find_by_id(key_id)
if not key_record or key_record.get("revoked_at"):
return VerifyApiKeyResult(valid=False)
if key_record.get("expires_at") and key_record["expires_at"] < datetime.now(timezone.utc):
return VerifyApiKeyResult(valid=False)
# Constant-time comparison via bcrypt
is_valid = bcrypt.checkpw(submitted_key.encode(), key_record["hash"].encode())
if is_valid:
# Update last used timestamp (async, non-blocking)
create_task(db.api_keys.update(key_id, {"last_used_at": datetime.now(timezone.utc)}))
return VerifyApiKeyResult(
valid=is_valid,
service_id=key_record["service_id"] if is_valid else None,
permissions=key_record["permissions"] if is_valid else None,
)using System.Security.Cryptography;
using Microsoft.AspNetCore.Cryptography.KeyDerivation;
// ============================================================================
// API Key Generation
// ============================================================================
public record ApiKey(
string Id,
string Prefix,
string Environment,
string ServiceId,
List<string> Permissions,
DateTime CreatedAt,
DateTime? LastUsedAt,
DateTime? ExpiresAt,
DateTime? RevokedAt
);
public record GenerateApiKeyResult(string KeyId, string FullKey);
public record VerifyApiKeyResult(bool Valid, string? ServiceId, List<string>? Permissions);
public class ApiKeyService
{
private readonly IApiKeyRepository _repository;
public ApiKeyService(IApiKeyRepository repository)
{
_repository = repository;
}
public async Task<GenerateApiKeyResult> GenerateApiKeyAsync(
string serviceId, string environment, List<string> permissions)
{
// Generate cryptographically random secret (32 bytes = 64 hex chars)
var secretBytes = RandomNumberGenerator.GetBytes(32);
var secretHex = Convert.ToHexString(secretBytes).ToLower();
// Build the key components
const string prefix = "sk";
var keyId = $"{prefix}_{environment}_{secretHex[..8]}";
var fullKey = $"{prefix}_{environment}_{secretHex}";
// Hash the full key for storage (BCrypt — intentionally slow)
var keyHash = BCrypt.Net.BCrypt.HashPassword(fullKey, workFactor: 12);
// Store in database
await _repository.InsertAsync(new ApiKeyRecord(
keyId, keyHash, serviceId, environment, permissions, DateTime.UtcNow
));
// Return full key to caller — shown only once
return new GenerateApiKeyResult(keyId, fullKey);
}
// ============================================================================
// API Key Verification
// ============================================================================
public async Task<VerifyApiKeyResult> VerifyApiKeyAsync(string submittedKey)
{
// Extract the key ID from the submitted key
// Format: sk_live_a7f3b9c2... — key ID is first 3 parts
var parts = submittedKey.Split('_');
if (parts.Length < 3)
return new VerifyApiKeyResult(false, null, null);
var keyId = $"{parts[0]}_{parts[1]}_{parts[2][..8]}";
// Look up the key record
var keyRecord = await _repository.FindByIdAsync(keyId);
if (keyRecord == null || keyRecord.RevokedAt.HasValue)
return new VerifyApiKeyResult(false, null, null);
if (keyRecord.ExpiresAt.HasValue && keyRecord.ExpiresAt.Value < DateTime.UtcNow)
return new VerifyApiKeyResult(false, null, null);
// Constant-time comparison via BCrypt
var isValid = BCrypt.Net.BCrypt.Verify(submittedKey, keyRecord.Hash);
if (isValid)
{
// Update last used timestamp (fire-and-forget)
_ = _repository.UpdateLastUsedAsync(keyId, DateTime.UtcNow);
}
return new VerifyApiKeyResult(
isValid,
isValid ? keyRecord.ServiceId : null,
isValid ? keyRecord.Permissions : null
);
}
}Part 2: HMAC Request Signing
Raw API keys are vulnerable: if a request is intercepted or logged, the key is exposed. HMAC signatures solve this by never transmitting the key — only a derived signature.
Canonical Request Format
For HMAC to provide request integrity, you need a canonical representation of the request that both sides agree on:
import crypto from 'crypto';
// ============================================================================
// Canonical Request Builder
// ============================================================================
interface SigningInput {
method: string;
path: string;
query: Record<string, string>;
headers: Record<string, string>;
body: string | Buffer;
timestamp: string; // ISO 8601
nonce: string;
}
function buildCanonicalRequest(input: SigningInput): string {
// 1. Canonical method (uppercase)
const canonicalMethod = input.method.toUpperCase();
// 2. Canonical path (URL-encode, preserve slashes)
const canonicalPath = encodeURIComponent(input.path)
.replace(/%2F/gi, '/');
// 3. Canonical query string (sorted by key, URL-encoded)
const canonicalQuery = Object.keys(input.query)
.sort()
.map(k => `${encodeURIComponent(k)}=${encodeURIComponent(input.query[k])}`)
.join('&');
// 4. Canonical headers (lowercase keys, trimmed values, sorted)
const headersToSign = ['content-type', 'host', 'x-timestamp', 'x-nonce'];
const canonicalHeaders = headersToSign
.filter(h => input.headers[h] !== undefined)
.sort()
.map(h => `${h}:${input.headers[h].trim()}`)
.join('\n');
const signedHeadersList = headersToSign
.filter(h => input.headers[h] !== undefined)
.sort()
.join(';');
// 5. Body hash (SHA-256 of request body)
const bodyHash = crypto
.createHash('sha256')
.update(typeof input.body === 'string' ? input.body : input.body)
.digest('hex');
// Combine into canonical string
return [
canonicalMethod,
canonicalPath,
canonicalQuery,
canonicalHeaders,
'',
signedHeadersList,
bodyHash,
].join('\n');
}
// ============================================================================
// HMAC-SHA256 Signature
// ============================================================================
function signRequest(
canonicalRequest: string,
secretKey: string,
timestamp: string,
keyId: string
): string {
// String to sign: algorithm + timestamp + hash of canonical request
const canonicalHash = crypto
.createHash('sha256')
.update(canonicalRequest)
.digest('hex');
const stringToSign = [
'HMAC-SHA256',
timestamp,
canonicalHash,
].join('\n');
// Derive signing key: HMAC(HMAC("MyService" + date, secretKey), keyId)
const date = timestamp.slice(0, 10).replace(/-/g, '');
const dateKey = crypto
.createHmac('sha256', `MyService${date}`)
.update(secretKey)
.digest();
const signingKey = crypto
.createHmac('sha256', dateKey)
.update(keyId)
.digest();
// Final HMAC signature
return crypto
.createHmac('sha256', signingKey)
.update(stringToSign)
.digest('hex');
}import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;
import java.util.stream.Collectors;
// ============================================================================
// Canonical Request Builder
// ============================================================================
public record SigningInput(
String method,
String path,
Map<String, String> query,
Map<String, String> headers,
byte[] body,
String timestamp, // ISO 8601
String nonce
) {}
public class HmacSigner {
public static String buildCanonicalRequest(SigningInput input) throws Exception {
// 1. Canonical method (uppercase)
String canonicalMethod = input.method().toUpperCase();
// 2. Canonical path (URL-encode, preserve slashes)
String canonicalPath = Arrays.stream(input.path().split("/"))
.map(seg -> URLEncoder.encode(seg, StandardCharsets.UTF_8))
.collect(Collectors.joining("/"));
// 3. Canonical query string (sorted by key, URL-encoded)
String canonicalQuery = input.query().entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.map(e -> URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8) + "="
+ URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8))
.collect(Collectors.joining("&"));
// 4. Canonical headers (lowercase keys, trimmed values, sorted)
List<String> headersToSign = List.of("content-type", "host", "x-timestamp", "x-nonce");
String canonicalHeaders = headersToSign.stream()
.filter(h -> input.headers().containsKey(h))
.sorted()
.map(h -> h + ":" + input.headers().get(h).trim())
.collect(Collectors.joining("\n"));
String signedHeadersList = headersToSign.stream()
.filter(h -> input.headers().containsKey(h))
.sorted()
.collect(Collectors.joining(";"));
// 5. Body hash (SHA-256 of request body)
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
byte[] bodyHashBytes = sha256.digest(input.body());
String bodyHash = HexFormat.of().formatHex(bodyHashBytes);
// Combine into canonical string
return String.join("\n",
canonicalMethod, canonicalPath, canonicalQuery,
canonicalHeaders, "", signedHeadersList, bodyHash
);
}
// ============================================================================
// HMAC-SHA256 Signature
// ============================================================================
public static String signRequest(
String canonicalRequest, String secretKey,
String timestamp, String keyId) throws Exception {
// String to sign: algorithm + timestamp + hash of canonical request
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
String canonicalHash = HexFormat.of()
.formatHex(sha256.digest(canonicalRequest.getBytes(StandardCharsets.UTF_8)));
String stringToSign = String.join("\n", "HMAC-SHA256", timestamp, canonicalHash);
// Derive signing key: HMAC(HMAC("MyService" + date, secretKey), keyId)
String date = timestamp.substring(0, 10).replace("-", "");
byte[] dateKey = hmacSha256(secretKey.getBytes(StandardCharsets.UTF_8),
("MyService" + date).getBytes(StandardCharsets.UTF_8));
byte[] signingKey = hmacSha256(keyId.getBytes(StandardCharsets.UTF_8), dateKey);
// Final HMAC signature
byte[] sigBytes = hmacSha256(
stringToSign.getBytes(StandardCharsets.UTF_8), signingKey);
return HexFormat.of().formatHex(sigBytes);
}
private static byte[] hmacSha256(byte[] data, byte[] key) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
return mac.doFinal(data);
}
}import hashlib
import hmac
import urllib.parse
from dataclasses import dataclass
# ============================================================================
# Canonical Request Builder
# ============================================================================
@dataclass
class SigningInput:
method: str
path: str
query: dict[str, str]
headers: dict[str, str]
body: bytes | str
timestamp: str # ISO 8601
nonce: str
def build_canonical_request(input: SigningInput) -> str:
# 1. Canonical method (uppercase)
canonical_method = input.method.upper()
# 2. Canonical path (URL-encode, preserve slashes)
canonical_path = "/".join(
urllib.parse.quote(seg, safe="") for seg in input.path.split("/")
)
# 3. Canonical query string (sorted by key, URL-encoded)
canonical_query = "&".join(
f"{urllib.parse.quote(k, safe='')}={urllib.parse.quote(v, safe='')}"
for k, v in sorted(input.query.items())
)
# 4. Canonical headers (lowercase keys, trimmed values, sorted)
headers_to_sign = ["content-type", "host", "x-timestamp", "x-nonce"]
present = sorted(h for h in headers_to_sign if h in input.headers)
canonical_headers = "\n".join(f"{h}:{input.headers[h].strip()}" for h in present)
signed_headers_list = ";".join(present)
# 5. Body hash (SHA-256 of request body)
body_bytes = input.body.encode() if isinstance(input.body, str) else input.body
body_hash = hashlib.sha256(body_bytes).hexdigest()
# Combine into canonical string
return "\n".join([
canonical_method,
canonical_path,
canonical_query,
canonical_headers,
"",
signed_headers_list,
body_hash,
])
# ============================================================================
# HMAC-SHA256 Signature
# ============================================================================
def sign_request(
canonical_request: str,
secret_key: str,
timestamp: str,
key_id: str,
) -> str:
# String to sign: algorithm + timestamp + hash of canonical request
canonical_hash = hashlib.sha256(canonical_request.encode()).hexdigest()
string_to_sign = "\n".join(["HMAC-SHA256", timestamp, canonical_hash])
# Derive signing key: HMAC(HMAC("MyService" + date, secretKey), keyId)
date = timestamp[:10].replace("-", "")
date_key = hmac.new(
secret_key.encode(), f"MyService{date}".encode(), hashlib.sha256
).digest()
signing_key = hmac.new(date_key, key_id.encode(), hashlib.sha256).digest()
# Final HMAC signature
return hmac.new(signing_key, string_to_sign.encode(), hashlib.sha256).hexdigest()using System.Security.Cryptography;
using System.Text;
using System.Web;
// ============================================================================
// Canonical Request Builder
// ============================================================================
public record SigningInput(
string Method,
string Path,
Dictionary<string, string> Query,
Dictionary<string, string> Headers,
byte[] Body,
string Timestamp, // ISO 8601
string Nonce
);
public static class HmacSigner
{
public static string BuildCanonicalRequest(SigningInput input)
{
// 1. Canonical method (uppercase)
var canonicalMethod = input.Method.ToUpper();
// 2. Canonical path (URL-encode, preserve slashes)
var canonicalPath = string.Join("/",
input.Path.Split('/').Select(seg => Uri.EscapeDataString(seg)));
// 3. Canonical query string (sorted by key, URL-encoded)
var canonicalQuery = string.Join("&",
input.Query
.OrderBy(kv => kv.Key)
.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
// 4. Canonical headers (lowercase keys, trimmed values, sorted)
var headersToSign = new[] { "content-type", "host", "x-timestamp", "x-nonce" };
var present = headersToSign
.Where(h => input.Headers.ContainsKey(h))
.OrderBy(h => h)
.ToList();
var canonicalHeaders = string.Join("\n",
present.Select(h => $"{h}:{input.Headers[h].Trim()}"));
var signedHeadersList = string.Join(";", present);
// 5. Body hash (SHA-256 of request body)
var bodyHash = Convert.ToHexString(SHA256.HashData(input.Body)).ToLower();
// Combine into canonical string
return string.Join("\n",
canonicalMethod, canonicalPath, canonicalQuery,
canonicalHeaders, "", signedHeadersList, bodyHash);
}
// ============================================================================
// HMAC-SHA256 Signature
// ============================================================================
public static string SignRequest(
string canonicalRequest, string secretKey,
string timestamp, string keyId)
{
// String to sign: algorithm + timestamp + hash of canonical request
var canonicalHash = Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(canonicalRequest))).ToLower();
var stringToSign = string.Join("\n", "HMAC-SHA256", timestamp, canonicalHash);
// Derive signing key: HMAC(HMAC("MyService" + date, secretKey), keyId)
var date = timestamp[..10].Replace("-", "");
var dateKey = HmacSha256(
Encoding.UTF8.GetBytes(secretKey),
Encoding.UTF8.GetBytes($"MyService{date}"));
var signingKey = HmacSha256(dateKey, Encoding.UTF8.GetBytes(keyId));
// Final HMAC signature
var sig = HmacSha256(signingKey, Encoding.UTF8.GetBytes(stringToSign));
return Convert.ToHexString(sig).ToLower();
}
private static byte[] HmacSha256(byte[] key, byte[] data)
{
using var mac = new HMACSHA256(key);
return mac.ComputeHash(data);
}
}Client-Side Request Signing
import https from 'https';
import { URL } from 'url';
import crypto from 'crypto';
// ============================================================================
// HMAC Request Client
// ============================================================================
interface HmacClientConfig {
keyId: string;
secretKey: string;
baseUrl: string;
serviceName: string;
}
class HmacHttpClient {
private config: HmacClientConfig;
constructor(config: HmacClientConfig) {
this.config = config;
}
private buildSignedHeaders(
method: string,
url: URL,
body: string
): Record<string, string> {
const timestamp = new Date().toISOString();
const nonce = crypto.randomBytes(16).toString('hex');
const query: Record<string, string> = {};
url.searchParams.forEach((v, k) => { query[k] = v; });
const headers: Record<string, string> = {
'content-type': 'application/json',
'host': url.host,
'x-timestamp': timestamp,
'x-nonce': nonce,
'x-key-id': this.config.keyId,
};
const canonical = buildCanonicalRequest({
method,
path: url.pathname,
query,
headers,
body,
timestamp,
nonce,
});
const signature = signRequest(
canonical,
this.config.secretKey,
timestamp,
this.config.keyId
);
headers['x-signature'] = signature;
headers['x-signed-headers'] = 'content-type;host;x-timestamp;x-nonce';
return headers;
}
async post<T>(path: string, body: unknown): Promise<T> {
const url = new URL(path, this.config.baseUrl);
const payload = JSON.stringify(body);
const headers = this.buildSignedHeaders('POST', url, payload);
headers['content-length'] = Buffer.byteLength(payload).toString();
return new Promise((resolve, reject) => {
const req = https.request(
{
hostname: url.hostname,
port: url.port || 443,
path: url.pathname + url.search,
method: 'POST',
headers,
timeout: 5000,
},
(res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
if (res.statusCode && res.statusCode >= 400) {
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
} else {
resolve(JSON.parse(data) as T);
}
});
}
);
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
req.write(payload);
req.end();
});
}
}
// Usage
const client = new HmacHttpClient({
keyId: 'sk_live_a7f3b9c2',
secretKey: process.env.SERVICE_API_SECRET!,
baseUrl: 'https://payment-service.internal',
serviceName: 'order-service',
});import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.net.URI;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.HexFormat;
import java.util.Map;
import java.util.TreeMap;
// ============================================================================
// HMAC Request Client
// ============================================================================
public record HmacClientConfig(
String keyId,
String secretKey,
String baseUrl,
String serviceName
) {}
@Component
public class HmacHttpClient {
private final HmacClientConfig config;
private final RestClient restClient;
private final SecureRandom secureRandom = new SecureRandom();
public HmacHttpClient(HmacClientConfig config) {
this.config = config;
this.restClient = RestClient.builder()
.baseUrl(config.baseUrl())
.build();
}
private HttpHeaders buildSignedHeaders(
String method, URI uri, String body) throws Exception {
String timestamp = Instant.now().toString();
byte[] nonceBytes = new byte[16];
secureRandom.nextBytes(nonceBytes);
String nonce = HexFormat.of().formatHex(nonceBytes);
Map<String, String> query = new TreeMap<>();
uri.getQuery() != null
? Arrays.stream(uri.getQuery().split("&"))
.forEach(p -> { var kv = p.split("=", 2); query.put(kv[0], kv[1]); })
: null;
Map<String, String> headers = new TreeMap<>();
headers.put("content-type", "application/json");
headers.put("host", uri.getHost());
headers.put("x-timestamp", timestamp);
headers.put("x-nonce", nonce);
var signingInput = new SigningInput(
method, uri.getPath(), query, headers,
body.getBytes(), timestamp, nonce
);
String canonical = HmacSigner.buildCanonicalRequest(signingInput);
String signature = HmacSigner.signRequest(
canonical, config.secretKey(), timestamp, config.keyId());
HttpHeaders httpHeaders = new HttpHeaders();
headers.forEach(httpHeaders::set);
httpHeaders.set("x-key-id", config.keyId());
httpHeaders.set("x-signature", signature);
httpHeaders.set("x-signed-headers", "content-type;host;x-timestamp;x-nonce");
return httpHeaders;
}
public <T> T post(String path, Object body, Class<T> responseType) throws Exception {
URI uri = URI.create(config.baseUrl() + path);
String payload = new ObjectMapper().writeValueAsString(body);
HttpHeaders headers = buildSignedHeaders("POST", uri, payload);
return restClient.post()
.uri(uri)
.headers(h -> h.addAll(headers))
.body(payload)
.retrieve()
.body(responseType);
}
}
// Usage
HmacHttpClient client = new HmacHttpClient(new HmacClientConfig(
"sk_live_a7f3b9c2",
System.getenv("SERVICE_API_SECRET"),
"https://payment-service.internal",
"order-service"
));import aiohttp
import secrets
import json
from datetime import datetime, timezone
from urllib.parse import urlparse, parse_qs
# ============================================================================
# HMAC Request Client
# ============================================================================
from dataclasses import dataclass
@dataclass
class HmacClientConfig:
key_id: str
secret_key: str
base_url: str
service_name: str
class HmacHttpClient:
def __init__(self, config: HmacClientConfig):
self.config = config
def _build_signed_headers(
self, method: str, url: str, body: str
) -> dict[str, str]:
parsed = urlparse(url)
timestamp = datetime.now(timezone.utc).isoformat()
nonce = secrets.token_hex(16)
query: dict[str, str] = {}
if parsed.query:
for k, v in parse_qs(parsed.query).items():
query[k] = v[0]
headers = {
"content-type": "application/json",
"host": parsed.netloc,
"x-timestamp": timestamp,
"x-nonce": nonce,
"x-key-id": self.config.key_id,
}
signing_input = SigningInput(
method=method,
path=parsed.path,
query=query,
headers=headers,
body=body.encode(),
timestamp=timestamp,
nonce=nonce,
)
canonical = build_canonical_request(signing_input)
signature = sign_request(
canonical, self.config.secret_key, timestamp, self.config.key_id
)
headers["x-signature"] = signature
headers["x-signed-headers"] = "content-type;host;x-timestamp;x-nonce"
return headers
async def post(self, path: str, body: object) -> dict:
url = self.config.base_url.rstrip("/") + path
payload = json.dumps(body)
headers = self._build_signed_headers("POST", url, payload)
headers["content-length"] = str(len(payload.encode()))
async with aiohttp.ClientSession() as session:
async with session.post(
url, data=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=5)
) as resp:
resp.raise_for_status()
return await resp.json()
# Usage
client = HmacHttpClient(HmacClientConfig(
key_id="sk_live_a7f3b9c2",
secret_key=os.environ["SERVICE_API_SECRET"],
base_url="https://payment-service.internal",
service_name="order-service",
))using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
// ============================================================================
// HMAC Request Client
// ============================================================================
public record HmacClientConfig(
string KeyId,
string SecretKey,
string BaseUrl,
string ServiceName
);
public class HmacHttpClient
{
private readonly HmacClientConfig _config;
private readonly HttpClient _httpClient;
public HmacHttpClient(HmacClientConfig config, HttpClient httpClient)
{
_config = config;
_httpClient = httpClient;
_httpClient.BaseAddress = new Uri(config.BaseUrl);
}
private Dictionary<string, string> BuildSignedHeaders(
string method, Uri uri, string body)
{
var timestamp = DateTime.UtcNow.ToString("o");
var nonce = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLower();
var query = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(uri.Query))
{
foreach (var part in uri.Query.TrimStart('?').Split('&'))
{
var kv = part.Split('=', 2);
if (kv.Length == 2) query[kv[0]] = kv[1];
}
}
var headers = new Dictionary<string, string>
{
["content-type"] = "application/json",
["host"] = uri.Host,
["x-timestamp"] = timestamp,
["x-nonce"] = nonce,
["x-key-id"] = _config.KeyId,
};
var signingInput = new SigningInput(
method, uri.AbsolutePath, query, headers,
Encoding.UTF8.GetBytes(body), timestamp, nonce);
var canonical = HmacSigner.BuildCanonicalRequest(signingInput);
var signature = HmacSigner.SignRequest(
canonical, _config.SecretKey, timestamp, _config.KeyId);
headers["x-signature"] = signature;
headers["x-signed-headers"] = "content-type;host;x-timestamp;x-nonce";
return headers;
}
public async Task<T?> PostAsync<T>(string path, object body)
{
var uri = new Uri(_config.BaseUrl.TrimEnd('/') + path);
var payload = JsonSerializer.Serialize(body);
var signedHeaders = BuildSignedHeaders("POST", uri, payload);
var request = new HttpRequestMessage(HttpMethod.Post, uri)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json")
};
foreach (var (key, value) in signedHeaders)
request.Headers.TryAddWithoutValidation(key, value);
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<T>();
}
}
// Usage
var client = new HmacHttpClient(
new HmacClientConfig(
KeyId: "sk_live_a7f3b9c2",
SecretKey: Environment.GetEnvironmentVariable("SERVICE_API_SECRET")!,
BaseUrl: "https://payment-service.internal",
ServiceName: "order-service"
),
new HttpClient()
);Server-Side Signature Verification
import express from 'express';
// ============================================================================
// HMAC Verification Middleware
// ============================================================================
const TIMESTAMP_TOLERANCE_SECONDS = 300; // 5 minutes
async function hmacAuthMiddleware(
req: express.Request,
res: express.Response,
next: express.NextFunction
): Promise<void> {
try {
// 1. Extract signature headers
const keyId = req.headers['x-key-id'] as string;
const signature = req.headers['x-signature'] as string;
const timestamp = req.headers['x-timestamp'] as string;
const nonce = req.headers['x-nonce'] as string;
if (!keyId || !signature || !timestamp || !nonce) {
res.status(401).json({ error: 'Missing authentication headers' });
return;
}
// 2. Verify timestamp to prevent replay attacks
const requestTime = new Date(timestamp).getTime();
const now = Date.now();
if (isNaN(requestTime)) {
res.status(401).json({ error: 'Invalid timestamp format' });
return;
}
const diffSeconds = Math.abs(now - requestTime) / 1000;
if (diffSeconds > TIMESTAMP_TOLERANCE_SECONDS) {
res.status(401).json({
error: 'Request timestamp too old or too far in future',
tolerance: `±${TIMESTAMP_TOLERANCE_SECONDS}s`,
});
return;
}
// 3. Check nonce to prevent replay within the tolerance window
const nonceKey = `hmac:nonce:${nonce}`;
const nonceExists = await redis.get(nonceKey);
if (nonceExists) {
res.status(401).json({ error: 'Duplicate nonce — possible replay attack' });
return;
}
// Store nonce with TTL slightly longer than replay window
await redis.setEx(nonceKey, TIMESTAMP_TOLERANCE_SECONDS * 2, '1');
// 4. Look up the secret key
const keyRecord = await db.apiKeys.findById(keyId);
if (!keyRecord || keyRecord.revokedAt) {
res.status(401).json({ error: 'Invalid key ID' });
return;
}
// 5. Reconstruct the canonical request from the incoming request
const query: Record<string, string> = {};
for (const [k, v] of Object.entries(req.query)) {
if (typeof v === 'string') query[k] = v;
}
const canonical = buildCanonicalRequest({
method: req.method,
path: req.path,
query,
headers: {
'content-type': req.headers['content-type'] || '',
'host': req.headers['host'] || '',
'x-timestamp': timestamp,
'x-nonce': nonce,
},
body: (req as any).rawBody || '',
timestamp,
nonce,
});
// 6. Recompute signature
const expectedSignature = signRequest(
canonical,
keyRecord.secretKey,
timestamp,
keyId
);
// 7. Constant-time comparison to prevent timing attacks
const sigBuffer = Buffer.from(signature, 'hex');
const expectedBuffer = Buffer.from(expectedSignature, 'hex');
if (
sigBuffer.length !== expectedBuffer.length ||
!crypto.timingSafeEqual(sigBuffer, expectedBuffer)
) {
console.warn('HMAC: Signature mismatch', { keyId, path: req.path });
res.status(401).json({ error: 'Invalid signature' });
return;
}
// 8. Attach service identity
(req as any).serviceId = keyRecord.serviceId;
(req as any).servicePermissions = keyRecord.permissions;
next();
} catch (error) {
console.error('HMAC middleware error', error);
res.status(500).json({ error: 'Authentication error' });
}
}
// ============================================================================
// Raw Body Capture (needed for body hash verification)
// ============================================================================
function captureRawBody(
req: express.Request,
res: express.Response,
next: express.NextFunction
): void {
let data = '';
req.on('data', (chunk) => { data += chunk; });
req.on('end', () => {
(req as any).rawBody = data;
next();
});
}
// Apply raw body capture BEFORE json parser
app.use(captureRawBody);
app.use(express.json({ verify: (req: any, res, buf) => { req.rawBody = buf; } }));import jakarta.servlet.*;
import jakarta.servlet.http.*;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.time.Duration;
import java.util.Map;
import java.util.TreeMap;
// ============================================================================
// HMAC Verification Filter
// ============================================================================
@Component
public class HmacAuthFilter extends OncePerRequestFilter {
private static final int TIMESTAMP_TOLERANCE_SECONDS = 300;
private final ApiKeyRepository apiKeyRepository;
private final StringRedisTemplate redisTemplate;
public HmacAuthFilter(ApiKeyRepository repo, StringRedisTemplate redis) {
this.apiKeyRepository = repo;
this.redisTemplate = redis;
}
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
// Cache request body for repeated reading
CachedBodyHttpServletRequest cachedRequest =
new CachedBodyHttpServletRequest(request);
try {
// 1. Extract signature headers
String keyId = request.getHeader("x-key-id");
String signature = request.getHeader("x-signature");
String timestamp = request.getHeader("x-timestamp");
String nonce = request.getHeader("x-nonce");
if (keyId == null || signature == null || timestamp == null || nonce == null) {
sendError(response, 401, "Missing authentication headers");
return;
}
// 2. Verify timestamp to prevent replay attacks
Instant requestTime = Instant.parse(timestamp);
long diffSeconds = Math.abs(
Instant.now().getEpochSecond() - requestTime.getEpochSecond());
if (diffSeconds > TIMESTAMP_TOLERANCE_SECONDS) {
sendError(response, 401, "Request timestamp too old or too far in future");
return;
}
// 3. Check nonce to prevent replay within the tolerance window
String nonceKey = "hmac:nonce:" + nonce;
Boolean nonceExists = redisTemplate.hasKey(nonceKey);
if (Boolean.TRUE.equals(nonceExists)) {
sendError(response, 401, "Duplicate nonce — possible replay attack");
return;
}
redisTemplate.opsForValue().set(nonceKey, "1",
Duration.ofSeconds(TIMESTAMP_TOLERANCE_SECONDS * 2L));
// 4. Look up the secret key
var keyRecord = apiKeyRepository.findById(keyId).orElse(null);
if (keyRecord == null || keyRecord.revokedAt() != null) {
sendError(response, 401, "Invalid key ID");
return;
}
// 5. Reconstruct the canonical request
byte[] rawBody = cachedRequest.getBody();
Map<String, String> query = new TreeMap<>();
request.getParameterMap().forEach((k, v) -> query.put(k, v[0]));
Map<String, String> headers = new TreeMap<>();
headers.put("content-type",
request.getHeader("content-type") != null ? request.getHeader("content-type") : "");
headers.put("host",
request.getHeader("host") != null ? request.getHeader("host") : "");
headers.put("x-timestamp", timestamp);
headers.put("x-nonce", nonce);
var signingInput = new SigningInput(
request.getMethod(), request.getRequestURI(),
query, headers, rawBody, timestamp, nonce);
String canonical = HmacSigner.buildCanonicalRequest(signingInput);
// 6. Recompute signature
String expectedSignature = HmacSigner.signRequest(
canonical, keyRecord.secretKey(), timestamp, keyId);
// 7. Constant-time comparison
byte[] sigBytes = HexFormat.of().parseHex(signature);
byte[] expectedBytes = HexFormat.of().parseHex(expectedSignature);
if (!MessageDigest.isEqual(sigBytes, expectedBytes)) {
sendError(response, 401, "Invalid signature");
return;
}
// 8. Attach service identity
cachedRequest.setAttribute("serviceId", keyRecord.serviceId());
cachedRequest.setAttribute("servicePermissions", keyRecord.permissions());
chain.doFilter(cachedRequest, response);
} catch (Exception e) {
sendError(response, 500, "Authentication error");
}
}
private void sendError(HttpServletResponse resp, int status, String msg)
throws IOException {
resp.setStatus(status);
resp.setContentType("application/json");
resp.getWriter().write("{\"error\":\"" + msg + "\"}");
}
}import hashlib
import hmac as hmac_lib
import json
from datetime import datetime, timezone
from functools import wraps
from fastapi import Request, HTTPException
from fastapi.responses import JSONResponse
# ============================================================================
# HMAC Verification Middleware (FastAPI)
# ============================================================================
TIMESTAMP_TOLERANCE_SECONDS = 300 # 5 minutes
async def hmac_auth_middleware(request: Request, call_next):
# 1. Extract signature headers
key_id = request.headers.get("x-key-id")
signature = request.headers.get("x-signature")
timestamp = request.headers.get("x-timestamp")
nonce = request.headers.get("x-nonce")
if not all([key_id, signature, timestamp, nonce]):
return JSONResponse({"error": "Missing authentication headers"}, status_code=401)
# 2. Verify timestamp to prevent replay attacks
try:
request_time = datetime.fromisoformat(timestamp)
except ValueError:
return JSONResponse({"error": "Invalid timestamp format"}, status_code=401)
diff_seconds = abs(
(datetime.now(timezone.utc) - request_time).total_seconds()
)
if diff_seconds > TIMESTAMP_TOLERANCE_SECONDS:
return JSONResponse(
{"error": "Request timestamp too old or too far in future"}, status_code=401
)
# 3. Check nonce to prevent replay
nonce_key = f"hmac:nonce:{nonce}"
if await redis.get(nonce_key):
return JSONResponse(
{"error": "Duplicate nonce — possible replay attack"}, status_code=401
)
await redis.setex(nonce_key, TIMESTAMP_TOLERANCE_SECONDS * 2, "1")
# 4. Look up the secret key
key_record = await db.api_keys.find_by_id(key_id)
if not key_record or key_record.get("revoked_at"):
return JSONResponse({"error": "Invalid key ID"}, status_code=401)
# 5. Reconstruct the canonical request
raw_body = await request.body()
query = dict(request.query_params)
signing_input = SigningInput(
method=request.method,
path=request.url.path,
query=query,
headers={
"content-type": request.headers.get("content-type", ""),
"host": request.headers.get("host", ""),
"x-timestamp": timestamp,
"x-nonce": nonce,
},
body=raw_body,
timestamp=timestamp,
nonce=nonce,
)
canonical = build_canonical_request(signing_input)
# 6. Recompute signature
expected_signature = sign_request(
canonical, key_record["secret_key"], timestamp, key_id
)
# 7. Constant-time comparison
if not hmac_lib.compare_digest(signature, expected_signature):
return JSONResponse({"error": "Invalid signature"}, status_code=401)
# 8. Attach service identity
request.state.service_id = key_record["service_id"]
request.state.service_permissions = key_record["permissions"]
return await call_next(request)
# Register in FastAPI app
# app.middleware("http")(hmac_auth_middleware)using Microsoft.AspNetCore.Http;
using System.Security.Cryptography;
using StackExchange.Redis;
// ============================================================================
// HMAC Verification Middleware
// ============================================================================
public class HmacAuthMiddleware
{
private const int TimestampToleranceSeconds = 300;
private readonly RequestDelegate _next;
private readonly IApiKeyRepository _repository;
private readonly IDatabase _redis;
public HmacAuthMiddleware(
RequestDelegate next, IApiKeyRepository repository, IDatabase redis)
{
_next = next;
_repository = repository;
_redis = redis;
}
public async Task InvokeAsync(HttpContext context)
{
var req = context.Request;
// 1. Extract signature headers
string? keyId = req.Headers["x-key-id"];
string? signature = req.Headers["x-signature"];
string? timestamp = req.Headers["x-timestamp"];
string? nonce = req.Headers["x-nonce"];
if (string.IsNullOrEmpty(keyId) || string.IsNullOrEmpty(signature)
|| string.IsNullOrEmpty(timestamp) || string.IsNullOrEmpty(nonce))
{
await WriteError(context, 401, "Missing authentication headers");
return;
}
// 2. Verify timestamp
if (!DateTime.TryParse(timestamp, out var requestTime))
{
await WriteError(context, 401, "Invalid timestamp format");
return;
}
var diffSeconds = Math.Abs((DateTime.UtcNow - requestTime.ToUniversalTime()).TotalSeconds);
if (diffSeconds > TimestampToleranceSeconds)
{
await WriteError(context, 401, "Request timestamp too old or too far in future");
return;
}
// 3. Check nonce
var nonceKey = $"hmac:nonce:{nonce}";
if (await _redis.KeyExistsAsync(nonceKey))
{
await WriteError(context, 401, "Duplicate nonce — possible replay attack");
return;
}
await _redis.StringSetAsync(nonceKey, "1",
TimeSpan.FromSeconds(TimestampToleranceSeconds * 2));
// 4. Look up the secret key
var keyRecord = await _repository.FindByIdAsync(keyId);
if (keyRecord == null || keyRecord.RevokedAt.HasValue)
{
await WriteError(context, 401, "Invalid key ID");
return;
}
// 5. Reconstruct the canonical request
req.EnableBuffering();
var rawBody = await new StreamReader(req.Body).ReadToEndAsync();
req.Body.Position = 0;
var query = req.Query.ToDictionary(kv => kv.Key, kv => kv.Value.ToString());
var headers = new Dictionary<string, string>
{
["content-type"] = req.ContentType ?? "",
["host"] = req.Host.Value,
["x-timestamp"] = timestamp,
["x-nonce"] = nonce,
};
var signingInput = new SigningInput(
req.Method, req.Path, query, headers,
System.Text.Encoding.UTF8.GetBytes(rawBody), timestamp, nonce);
var canonical = HmacSigner.BuildCanonicalRequest(signingInput);
// 6. Recompute signature
var expectedSignature = HmacSigner.SignRequest(
canonical, keyRecord.SecretKey, timestamp, keyId);
// 7. Constant-time comparison
var sigBytes = Convert.FromHexString(signature);
var expectedBytes = Convert.FromHexString(expectedSignature);
if (!CryptographicOperations.FixedTimeEquals(sigBytes, expectedBytes))
{
await WriteError(context, 401, "Invalid signature");
return;
}
// 8. Attach service identity
context.Items["ServiceId"] = keyRecord.ServiceId;
context.Items["ServicePermissions"] = keyRecord.Permissions;
await _next(context);
}
private static async Task WriteError(HttpContext ctx, int status, string message)
{
ctx.Response.StatusCode = status;
ctx.Response.ContentType = "application/json";
await ctx.Response.WriteAsync($"{{\"error\":\"{message}\"}}");
}
}
// Register in Program.cs: app.UseMiddleware<HmacAuthMiddleware>();Part 3: Rate Limiting and Key Management
Per-Key Rate Limiting
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
// ============================================================================
// Dynamic Rate Limiter by API Key
// ============================================================================
// Different rate limits per key tier
const RATE_LIMITS: Record<string, { windowMs: number; max: number }> = {
basic: { windowMs: 60_000, max: 100 }, // 100/min
standard: { windowMs: 60_000, max: 1_000 }, // 1K/min
premium: { windowMs: 60_000, max: 10_000 }, // 10K/min
};
function createKeyRateLimiter() {
return rateLimit({
windowMs: 60_000, // Default window
max: async (req) => {
const keyId = req.headers['x-key-id'] as string;
if (!keyId) return 10; // Very low limit for unsigned requests
const keyRecord = await db.apiKeys.findById(keyId);
if (!keyRecord) return 10;
const tier = keyRecord.tier || 'basic';
return RATE_LIMITS[tier]?.max ?? 100;
},
keyGenerator: (req) => {
return (req.headers['x-key-id'] as string) || req.ip;
},
store: new RedisStore({
sendCommand: (...args: string[]) => redis.sendCommand(args),
}),
handler: (req, res) => {
res.status(429).json({
error: 'Rate limit exceeded',
retryAfter: res.getHeader('Retry-After'),
});
},
});
}import io.github.bucket4j.*;
import io.github.bucket4j.redis.lettuce.cas.LettuceBasedProxyManager;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
// ============================================================================
// Dynamic Rate Limiter by API Key (Bucket4j + Redis)
// ============================================================================
// Different rate limits per key tier
enum KeyTier { BASIC, STANDARD, PREMIUM }
record TierLimit(long capacity, Duration refillPeriod) {}
@Component
public class ApiKeyRateLimiter {
private static final Map<KeyTier, TierLimit> RATE_LIMITS = Map.of(
KeyTier.BASIC, new TierLimit(100, Duration.ofMinutes(1)),
KeyTier.STANDARD, new TierLimit(1_000, Duration.ofMinutes(1)),
KeyTier.PREMIUM, new TierLimit(10_000, Duration.ofMinutes(1))
);
private final LettuceBasedProxyManager<String> proxyManager;
private final ApiKeyRepository apiKeyRepository;
public ApiKeyRateLimiter(LettuceBasedProxyManager<String> proxyManager,
ApiKeyRepository repo) {
this.proxyManager = proxyManager;
this.apiKeyRepository = repo;
}
public boolean tryConsume(String keyId) {
var keyRecord = apiKeyRepository.findById(keyId).orElse(null);
KeyTier tier = keyRecord != null ? keyRecord.tier() : KeyTier.BASIC;
TierLimit limit = RATE_LIMITS.getOrDefault(tier, RATE_LIMITS.get(KeyTier.BASIC));
BucketConfiguration config = BucketConfiguration.builder()
.addLimit(Bandwidth.classic(
limit.capacity(),
Refill.greedy(limit.capacity(), limit.refillPeriod())))
.build();
Bucket bucket = proxyManager.builder().build("ratelimit:" + keyId, config);
return bucket.tryConsume(1);
}
// Use as a Spring HandlerInterceptor
public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler)
throws Exception {
String keyId = req.getHeader("x-key-id");
String identifier = keyId != null ? keyId : req.getRemoteAddr();
if (!tryConsume(identifier)) {
resp.setStatus(429);
resp.setContentType("application/json");
resp.getWriter().write("{\"error\":\"Rate limit exceeded\"}");
return false;
}
return true;
}
}import time
from fastapi import Request
from fastapi.responses import JSONResponse
# ============================================================================
# Dynamic Rate Limiter by API Key (Redis sliding window)
# ============================================================================
# Different rate limits per key tier
RATE_LIMITS = {
"basic": {"window_ms": 60_000, "max": 100},
"standard": {"window_ms": 60_000, "max": 1_000},
"premium": {"window_ms": 60_000, "max": 10_000},
}
async def key_rate_limit_middleware(request: Request, call_next):
key_id = request.headers.get("x-key-id")
if not key_id:
# Very low limit for unsigned requests
identifier = request.client.host if request.client else "unknown"
max_requests = 10
window_ms = 60_000
else:
key_record = await db.api_keys.find_by_id(key_id)
tier = key_record.get("tier", "basic") if key_record else "basic"
limits = RATE_LIMITS.get(tier, RATE_LIMITS["basic"])
identifier = key_id
max_requests = limits["max"]
window_ms = limits["window_ms"]
# Sliding window counter in Redis
now_ms = int(time.time() * 1000)
window_start = now_ms - window_ms
redis_key = f"ratelimit:{identifier}"
pipe = redis.pipeline()
pipe.zremrangebyscore(redis_key, 0, window_start)
pipe.zcard(redis_key)
pipe.zadd(redis_key, {str(now_ms): now_ms})
pipe.pexpire(redis_key, window_ms)
results = await pipe.execute()
current_count = results[1]
if current_count >= max_requests:
retry_after = window_ms // 1000
return JSONResponse(
{"error": "Rate limit exceeded", "retryAfter": retry_after},
status_code=429,
headers={"Retry-After": str(retry_after)},
)
return await call_next(request)using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;
using StackExchange.Redis;
// ============================================================================
// Dynamic Rate Limiter by API Key (ASP.NET Core + Redis)
// ============================================================================
// Different rate limits per key tier
public static class RateLimitTiers
{
public static readonly Dictionary<string, (int Max, TimeSpan Window)> Limits = new()
{
["basic"] = (100, TimeSpan.FromMinutes(1)),
["standard"] = (1_000, TimeSpan.FromMinutes(1)),
["premium"] = (10_000, TimeSpan.FromMinutes(1)),
};
}
public class ApiKeyRateLimitMiddleware
{
private readonly RequestDelegate _next;
private readonly IApiKeyRepository _repository;
private readonly IDatabase _redis;
public ApiKeyRateLimitMiddleware(
RequestDelegate next, IApiKeyRepository repo, IDatabase redis)
{
_next = next;
_repository = repo;
_redis = redis;
}
public async Task InvokeAsync(HttpContext context)
{
var req = context.Request;
var keyId = req.Headers["x-key-id"].ToString();
string identifier;
int maxRequests;
TimeSpan window;
if (string.IsNullOrEmpty(keyId))
{
identifier = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
maxRequests = 10;
window = TimeSpan.FromMinutes(1);
}
else
{
var keyRecord = await _repository.FindByIdAsync(keyId);
var tier = keyRecord?.Tier ?? "basic";
(maxRequests, window) = RateLimitTiers.Limits.GetValueOrDefault(tier,
RateLimitTiers.Limits["basic"]);
identifier = keyId;
}
// Sliding window in Redis
var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var windowStartMs = nowMs - (long)window.TotalMilliseconds;
var redisKey = $"ratelimit:{identifier}";
var transaction = _redis.CreateTransaction();
_ = transaction.SortedSetRemoveRangeByScoreAsync(redisKey, 0, windowStartMs);
var countTask = transaction.SortedSetLengthAsync(redisKey);
_ = transaction.SortedSetAddAsync(redisKey, nowMs.ToString(), nowMs);
_ = transaction.KeyExpireAsync(redisKey, window);
await transaction.ExecuteAsync();
var currentCount = await countTask;
if (currentCount >= maxRequests)
{
context.Response.StatusCode = 429;
context.Response.Headers["Retry-After"] = ((int)window.TotalSeconds).ToString();
await context.Response.WriteAsJsonAsync(new
{
error = "Rate limit exceeded",
retryAfter = (int)window.TotalSeconds
});
return;
}
await _next(context);
}
}Key Rotation Without Downtime
The challenge with API key rotation is the transition period: when you generate a new key, existing callers still have the old key. The safest pattern is dual-key support during the rotation window:
// ============================================================================
// Key Rotation Strategy
// ============================================================================
interface KeyRotationResult {
oldKeyId: string;
newKeyId: string;
newFullKey: string;
rotationDeadline: Date; // Old key expires at this time
}
async function rotateApiKey(
oldKeyId: string,
rotationWindowHours = 24
): Promise<KeyRotationResult> {
const oldKey = await db.apiKeys.findById(oldKeyId);
if (!oldKey) throw new Error(`Key not found: ${oldKeyId}`);
// 1. Generate new key for the same service
const { keyId: newKeyId, fullKey: newFullKey } = await generateApiKey(
oldKey.serviceId,
oldKey.environment,
oldKey.permissions
);
// 2. Set rotation deadline on old key
const rotationDeadline = new Date();
rotationDeadline.setHours(rotationDeadline.getHours() + rotationWindowHours);
await db.apiKeys.update(oldKeyId, {
expiresAt: rotationDeadline,
rotationStatus: 'retiring',
replacedBy: newKeyId,
});
// 3. Both old and new keys are valid until the deadline
console.info('Key rotation initiated', {
oldKeyId,
newKeyId,
rotationDeadline,
windowHours: rotationWindowHours,
});
return { oldKeyId, newKeyId, newFullKey, rotationDeadline };
}
// ============================================================================
// Key Revocation
// ============================================================================
async function revokeApiKey(keyId: string, reason: string): Promise<void> {
await db.apiKeys.update(keyId, {
revokedAt: new Date(),
revocationReason: reason,
});
// Optionally: publish revocation event so other services can clear caches
await eventBus.publish('api-key.revoked', { keyId, reason });
console.info('API key revoked', { keyId, reason });
}import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
// ============================================================================
// Key Rotation Strategy
// ============================================================================
public record KeyRotationResult(
String oldKeyId,
String newKeyId,
String newFullKey,
Instant rotationDeadline // Old key expires at this time
) {}
@Service
public class ApiKeyRotationService {
private final ApiKeyService apiKeyService;
private final ApiKeyRepository apiKeyRepository;
private final EventBus eventBus;
public ApiKeyRotationResult rotateApiKey(
String oldKeyId, int rotationWindowHours) throws Exception {
var oldKey = apiKeyRepository.findById(oldKeyId)
.orElseThrow(() -> new IllegalArgumentException("Key not found: " + oldKeyId));
// 1. Generate new key for the same service
var result = apiKeyService.generateApiKey(
oldKey.serviceId(), oldKey.environment(), oldKey.permissions());
// 2. Set rotation deadline on old key
Instant rotationDeadline = Instant.now().plus(rotationWindowHours, ChronoUnit.HOURS);
apiKeyRepository.update(oldKeyId, Map.of(
"expiresAt", rotationDeadline,
"rotationStatus", "retiring",
"replacedBy", result.keyId()
));
// 3. Both old and new keys are valid until the deadline
log.info("Key rotation initiated: oldKeyId={}, newKeyId={}, deadline={}",
oldKeyId, result.keyId(), rotationDeadline);
return new KeyRotationResult(
oldKeyId, result.keyId(), result.fullKey(), rotationDeadline);
}
// ============================================================================
// Key Revocation
// ============================================================================
public void revokeApiKey(String keyId, String reason) {
apiKeyRepository.update(keyId, Map.of(
"revokedAt", Instant.now(),
"revocationReason", reason
));
// Publish revocation event so other services can clear caches
eventBus.publish("api-key.revoked", Map.of("keyId", keyId, "reason", reason));
log.info("API key revoked: keyId={}, reason={}", keyId, reason);
}
}import logging
from datetime import datetime, timezone, timedelta
logger = logging.getLogger(__name__)
# ============================================================================
# Key Rotation Strategy
# ============================================================================
from dataclasses import dataclass
@dataclass
class KeyRotationResult:
old_key_id: str
new_key_id: str
new_full_key: str
rotation_deadline: datetime # Old key expires at this time
async def rotate_api_key(
old_key_id: str,
rotation_window_hours: int = 24,
) -> KeyRotationResult:
old_key = await db.api_keys.find_by_id(old_key_id)
if not old_key:
raise ValueError(f"Key not found: {old_key_id}")
# 1. Generate new key for the same service
result = await generate_api_key(
old_key["service_id"],
old_key["environment"],
old_key["permissions"],
)
# 2. Set rotation deadline on old key
rotation_deadline = datetime.now(timezone.utc) + timedelta(hours=rotation_window_hours)
await db.api_keys.update(old_key_id, {
"expires_at": rotation_deadline,
"rotation_status": "retiring",
"replaced_by": result.key_id,
})
# 3. Both old and new keys are valid until the deadline
logger.info(
"Key rotation initiated",
extra={
"old_key_id": old_key_id,
"new_key_id": result.key_id,
"rotation_deadline": rotation_deadline.isoformat(),
"window_hours": rotation_window_hours,
},
)
return KeyRotationResult(
old_key_id=old_key_id,
new_key_id=result.key_id,
new_full_key=result.full_key,
rotation_deadline=rotation_deadline,
)
# ============================================================================
# Key Revocation
# ============================================================================
async def revoke_api_key(key_id: str, reason: str) -> None:
await db.api_keys.update(key_id, {
"revoked_at": datetime.now(timezone.utc),
"revocation_reason": reason,
})
# Publish revocation event so other services can clear caches
await event_bus.publish("api-key.revoked", {"key_id": key_id, "reason": reason})
logger.info("API key revoked", extra={"key_id": key_id, "reason": reason})using Microsoft.Extensions.Logging;
// ============================================================================
// Key Rotation Strategy
// ============================================================================
public record KeyRotationResult(
string OldKeyId,
string NewKeyId,
string NewFullKey,
DateTime RotationDeadline // Old key expires at this time
);
public class ApiKeyRotationService
{
private readonly ApiKeyService _apiKeyService;
private readonly IApiKeyRepository _repository;
private readonly IEventBus _eventBus;
private readonly ILogger<ApiKeyRotationService> _logger;
public ApiKeyRotationService(
ApiKeyService apiKeyService,
IApiKeyRepository repository,
IEventBus eventBus,
ILogger<ApiKeyRotationService> logger)
{
_apiKeyService = apiKeyService;
_repository = repository;
_eventBus = eventBus;
_logger = logger;
}
public async Task<KeyRotationResult> RotateApiKeyAsync(
string oldKeyId, int rotationWindowHours = 24)
{
var oldKey = await _repository.FindByIdAsync(oldKeyId)
?? throw new ArgumentException($"Key not found: {oldKeyId}");
// 1. Generate new key for the same service
var result = await _apiKeyService.GenerateApiKeyAsync(
oldKey.ServiceId, oldKey.Environment, oldKey.Permissions);
// 2. Set rotation deadline on old key
var rotationDeadline = DateTime.UtcNow.AddHours(rotationWindowHours);
await _repository.UpdateAsync(oldKeyId, new
{
ExpiresAt = rotationDeadline,
RotationStatus = "retiring",
ReplacedBy = result.KeyId,
});
// 3. Both old and new keys are valid until the deadline
_logger.LogInformation(
"Key rotation initiated: oldKeyId={OldKeyId}, newKeyId={NewKeyId}, deadline={Deadline}",
oldKeyId, result.KeyId, rotationDeadline);
return new KeyRotationResult(oldKeyId, result.KeyId, result.FullKey, rotationDeadline);
}
// ============================================================================
// Key Revocation
// ============================================================================
public async Task RevokeApiKeyAsync(string keyId, string reason)
{
await _repository.UpdateAsync(keyId, new
{
RevokedAt = DateTime.UtcNow,
RevocationReason = reason,
});
// Publish revocation event so other services can clear caches
await _eventBus.PublishAsync("api-key.revoked", new { KeyId = keyId, Reason = reason });
_logger.LogInformation("API key revoked: keyId={KeyId}, reason={Reason}", keyId, reason);
}
}Part 4: Webhook HMAC Verification
Webhooks are a common use case for HMAC — verifying that a payload from GitHub, Stripe, or any webhook provider genuinely came from them:
// ============================================================================
// Generic Webhook HMAC Verifier
// ============================================================================
interface WebhookVerificationConfig {
secret: string;
signatureHeader: string; // e.g. 'x-hub-signature-256'
signaturePrefix?: string; // e.g. 'sha256='
algorithm?: string; // default: 'sha256'
toleranceSeconds?: number; // default: 300
timestampHeader?: string; // optional timestamp header
}
function createWebhookVerifier(config: WebhookVerificationConfig) {
const {
secret,
signatureHeader,
signaturePrefix = '',
algorithm = 'sha256',
toleranceSeconds = 300,
timestampHeader,
} = config;
return function verifyWebhook(
req: express.Request,
res: express.Response,
next: express.NextFunction
): void {
const rawBody = (req as any).rawBody as Buffer;
if (!rawBody) {
res.status(400).json({ error: 'Raw body not available' });
return;
}
// Verify timestamp if provided (prevents replay)
if (timestampHeader) {
const timestamp = req.headers[timestampHeader] as string;
if (!timestamp) {
res.status(401).json({ error: `Missing ${timestampHeader} header` });
return;
}
const age = Math.abs(Date.now() / 1000 - parseInt(timestamp, 10));
if (age > toleranceSeconds) {
res.status(401).json({ error: 'Webhook timestamp too old' });
return;
}
}
// Extract signature
const signatureHeader_ = req.headers[signatureHeader] as string;
if (!signatureHeader_) {
res.status(401).json({ error: `Missing ${signatureHeader} header` });
return;
}
const receivedSig = signatureHeader_.startsWith(signaturePrefix)
? signatureHeader_.slice(signaturePrefix.length)
: signatureHeader_;
// Recompute
const expectedSig = crypto
.createHmac(algorithm, secret)
.update(rawBody)
.digest('hex');
// Constant-time comparison
const received = Buffer.from(receivedSig, 'hex');
const expected = Buffer.from(expectedSig, 'hex');
if (received.length !== expected.length || !crypto.timingSafeEqual(received, expected)) {
res.status(401).json({ error: 'Invalid webhook signature' });
return;
}
next();
};
}
// ============================================================================
// Pre-configured verifiers for common providers
// ============================================================================
// GitHub webhooks
const verifyGitHub = createWebhookVerifier({
secret: process.env.GITHUB_WEBHOOK_SECRET!,
signatureHeader: 'x-hub-signature-256',
signaturePrefix: 'sha256=',
});
// Stripe webhooks
const verifyStripe = createWebhookVerifier({
secret: process.env.STRIPE_WEBHOOK_SECRET!,
signatureHeader: 'stripe-signature',
signaturePrefix: 'v1=',
timestampHeader: 'stripe-signature', // Stripe embeds timestamp in the header
});
// Usage
app.post('/webhooks/github', verifyGitHub, (req, res) => {
const event = req.headers['x-github-event'];
console.log(`GitHub webhook: ${event}`);
res.json({ received: true });
});
app.post('/webhooks/stripe', verifyStripe, (req, res) => {
const event = req.body;
console.log(`Stripe webhook: ${event.type}`);
res.json({ received: true });
});import jakarta.servlet.http.*;
import org.springframework.stereotype.Component;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
// ============================================================================
// Generic Webhook HMAC Verifier
// ============================================================================
public record WebhookVerificationConfig(
String secret,
String signatureHeader,
String signaturePrefix, // e.g. "sha256="
String algorithm, // default: "HmacSHA256"
int toleranceSeconds, // default: 300
String timestampHeader // optional
) {
public WebhookVerificationConfig(String secret, String signatureHeader) {
this(secret, signatureHeader, "", "HmacSHA256", 300, null);
}
}
@Component
public class WebhookVerifierFactory {
public HandlerInterceptor create(WebhookVerificationConfig config) {
return new HandlerInterceptor() {
@Override
public boolean preHandle(HttpServletRequest req,
HttpServletResponse resp, Object handler) throws Exception {
byte[] rawBody = req.getInputStream().readAllBytes();
// Verify timestamp if configured
if (config.timestampHeader() != null) {
String ts = req.getHeader(config.timestampHeader());
if (ts == null) { sendError(resp, 401, "Missing timestamp header"); return false; }
long age = Math.abs(System.currentTimeMillis() / 1000L - Long.parseLong(ts));
if (age > config.toleranceSeconds()) {
sendError(resp, 401, "Webhook timestamp too old"); return false;
}
}
// Extract signature
String sigHeader = req.getHeader(config.signatureHeader());
if (sigHeader == null) { sendError(resp, 401, "Missing signature header"); return false; }
String receivedSig = sigHeader.startsWith(config.signaturePrefix())
? sigHeader.substring(config.signaturePrefix().length())
: sigHeader;
// Recompute
Mac mac = Mac.getInstance(config.algorithm());
mac.init(new SecretKeySpec(config.secret().getBytes(), config.algorithm()));
String expectedSig = HexFormat.of().formatHex(mac.doFinal(rawBody));
// Constant-time comparison
if (!MessageDigest.isEqual(
HexFormat.of().parseHex(receivedSig),
HexFormat.of().parseHex(expectedSig))) {
sendError(resp, 401, "Invalid webhook signature");
return false;
}
req.setAttribute("rawBody", rawBody);
return true;
}
};
}
private void sendError(HttpServletResponse resp, int status, String msg) throws Exception {
resp.setStatus(status);
resp.setContentType("application/json");
resp.getWriter().write("{\"error\":\"" + msg + "\"}");
}
// Pre-configured verifiers
public HandlerInterceptor gitHub(String secret) {
return create(new WebhookVerificationConfig(
secret, "x-hub-signature-256", "sha256=", "HmacSHA256", 300, null));
}
public HandlerInterceptor stripe(String secret) {
return create(new WebhookVerificationConfig(
secret, "stripe-signature", "v1=", "HmacSHA256", 300, null));
}
}import hashlib
import hmac as hmac_lib
import time
from dataclasses import dataclass, field
from typing import Optional, Callable
from fastapi import Request
from fastapi.responses import JSONResponse
# ============================================================================
# Generic Webhook HMAC Verifier
# ============================================================================
@dataclass
class WebhookVerificationConfig:
secret: str
signature_header: str
signature_prefix: str = ""
algorithm: str = "sha256"
tolerance_seconds: int = 300
timestamp_header: Optional[str] = None
def create_webhook_verifier(config: WebhookVerificationConfig):
async def verify_webhook(request: Request, call_next):
raw_body = await request.body()
# Verify timestamp if provided
if config.timestamp_header:
ts = request.headers.get(config.timestamp_header)
if not ts:
return JSONResponse(
{"error": f"Missing {config.timestamp_header} header"}, status_code=401
)
age = abs(time.time() - float(ts))
if age > config.tolerance_seconds:
return JSONResponse({"error": "Webhook timestamp too old"}, status_code=401)
# Extract signature
sig_header = request.headers.get(config.signature_header)
if not sig_header:
return JSONResponse(
{"error": f"Missing {config.signature_header} header"}, status_code=401
)
received_sig = (
sig_header[len(config.signature_prefix):]
if sig_header.startswith(config.signature_prefix)
else sig_header
)
# Recompute
expected_sig = hmac_lib.new(
config.secret.encode(), raw_body, config.algorithm
).hexdigest()
# Constant-time comparison
if not hmac_lib.compare_digest(received_sig, expected_sig):
return JSONResponse({"error": "Invalid webhook signature"}, status_code=401)
return await call_next(request)
return verify_webhook
# Pre-configured verifiers
verify_github = create_webhook_verifier(WebhookVerificationConfig(
secret=os.environ["GITHUB_WEBHOOK_SECRET"],
signature_header="x-hub-signature-256",
signature_prefix="sha256=",
))
verify_stripe = create_webhook_verifier(WebhookVerificationConfig(
secret=os.environ["STRIPE_WEBHOOK_SECRET"],
signature_header="stripe-signature",
signature_prefix="v1=",
))
# Usage with FastAPI router
# router.add_middleware(verify_github) — or use as a dependencyusing Microsoft.AspNetCore.Http;
using System.Security.Cryptography;
using System.Text;
// ============================================================================
// Generic Webhook HMAC Verifier
// ============================================================================
public record WebhookVerificationConfig(
string Secret,
string SignatureHeader,
string SignaturePrefix = "",
string Algorithm = "SHA256",
int ToleranceSeconds = 300,
string? TimestampHeader = null
);
public class WebhookVerifierMiddleware
{
private readonly RequestDelegate _next;
private readonly WebhookVerificationConfig _config;
public WebhookVerifierMiddleware(RequestDelegate next, WebhookVerificationConfig config)
{
_next = next;
_config = config;
}
public async Task InvokeAsync(HttpContext context)
{
context.Request.EnableBuffering();
var rawBody = await new StreamReader(context.Request.Body).ReadToEndAsync();
context.Request.Body.Position = 0;
// Verify timestamp if configured
if (_config.TimestampHeader != null)
{
var ts = context.Request.Headers[_config.TimestampHeader].ToString();
if (string.IsNullOrEmpty(ts))
{
await WriteError(context, 401, $"Missing {_config.TimestampHeader} header");
return;
}
var age = Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(ts));
if (age > _config.ToleranceSeconds)
{
await WriteError(context, 401, "Webhook timestamp too old");
return;
}
}
// Extract signature
var sigHeader = context.Request.Headers[_config.SignatureHeader].ToString();
if (string.IsNullOrEmpty(sigHeader))
{
await WriteError(context, 401, $"Missing {_config.SignatureHeader} header");
return;
}
var receivedSig = sigHeader.StartsWith(_config.SignaturePrefix)
? sigHeader[_config.SignaturePrefix.Length..]
: sigHeader;
// Recompute
using var mac = new HMACSHA256(Encoding.UTF8.GetBytes(_config.Secret));
var expectedSig = Convert.ToHexString(
mac.ComputeHash(Encoding.UTF8.GetBytes(rawBody))).ToLower();
// Constant-time comparison
var received = Convert.FromHexString(receivedSig);
var expected = Convert.FromHexString(expectedSig);
if (!CryptographicOperations.FixedTimeEquals(received, expected))
{
await WriteError(context, 401, "Invalid webhook signature");
return;
}
context.Items["RawBody"] = rawBody;
await _next(context);
}
private static async Task WriteError(HttpContext ctx, int status, string message)
{
ctx.Response.StatusCode = status;
ctx.Response.ContentType = "application/json";
await ctx.Response.WriteAsync($"{{\"error\":\"{message}\"}}");
}
}
// Pre-configured factory
public static class WebhookVerifiers
{
public static WebhookVerificationConfig GitHub(string secret) =>
new(secret, "x-hub-signature-256", "sha256=");
public static WebhookVerificationConfig Stripe(string secret) =>
new(secret, "stripe-signature", "v1=");
}Part 5: Caching Key Lookups
Database lookups on every request are a performance bottleneck. Cache key records in Redis:
// ============================================================================
// Cached Key Lookup
// ============================================================================
const KEY_CACHE_TTL = 300; // 5 minutes
async function getKeyRecord(keyId: string): Promise<ApiKeyRecord | null> {
const cacheKey = `apikey:${keyId}`;
// Check cache first
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached) as ApiKeyRecord;
}
// Miss: load from database
const record = await db.apiKeys.findById(keyId);
if (record) {
// Cache for TTL (don't cache revoked keys — those should fail immediately)
if (!record.revokedAt) {
await redis.setEx(cacheKey, KEY_CACHE_TTL, JSON.stringify(record));
}
}
return record;
}
// When a key is revoked, invalidate its cache entry immediately
async function revokeAndInvalidate(keyId: string, reason: string): Promise<void> {
await db.apiKeys.update(keyId, { revokedAt: new Date(), revocationReason: reason });
await redis.del(`apikey:${keyId}`);
console.info('Key revoked and cache invalidated', { keyId });
}import org.springframework.cache.annotation.*;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Duration;
// ============================================================================
// Cached Key Lookup
// ============================================================================
@Service
public class CachedApiKeyService {
private static final int KEY_CACHE_TTL = 300; // 5 minutes
private final ApiKeyRepository repository;
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
public CachedApiKeyService(
ApiKeyRepository repository,
StringRedisTemplate redisTemplate,
ObjectMapper objectMapper) {
this.repository = repository;
this.redisTemplate = redisTemplate;
this.objectMapper = objectMapper;
}
public ApiKeyRecord getKeyRecord(String keyId) throws Exception {
String cacheKey = "apikey:" + keyId;
// Check cache first
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return objectMapper.readValue(cached, ApiKeyRecord.class);
}
// Miss: load from database
ApiKeyRecord record = repository.findById(keyId).orElse(null);
if (record != null && record.revokedAt() == null) {
// Cache for TTL (don't cache revoked keys)
redisTemplate.opsForValue().set(
cacheKey,
objectMapper.writeValueAsString(record),
Duration.ofSeconds(KEY_CACHE_TTL)
);
}
return record;
}
// When a key is revoked, invalidate its cache entry immediately
public void revokeAndInvalidate(String keyId, String reason) {
repository.update(keyId, Map.of(
"revokedAt", Instant.now(),
"revocationReason", reason
));
redisTemplate.delete("apikey:" + keyId);
log.info("Key revoked and cache invalidated: keyId={}", keyId);
}
}import json
import logging
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
# ============================================================================
# Cached Key Lookup
# ============================================================================
KEY_CACHE_TTL = 300 # 5 minutes
async def get_key_record(key_id: str) -> dict | None:
cache_key = f"apikey:{key_id}"
# Check cache first
cached = await redis.get(cache_key)
if cached:
return json.loads(cached)
# Miss: load from database
record = await db.api_keys.find_by_id(key_id)
if record:
# Cache for TTL (don't cache revoked keys — those should fail immediately)
if not record.get("revoked_at"):
await redis.setex(cache_key, KEY_CACHE_TTL, json.dumps(record, default=str))
return record
# When a key is revoked, invalidate its cache entry immediately
async def revoke_and_invalidate(key_id: str, reason: str) -> None:
await db.api_keys.update(key_id, {
"revoked_at": datetime.now(timezone.utc),
"revocation_reason": reason,
})
await redis.delete(f"apikey:{key_id}")
logger.info("Key revoked and cache invalidated", extra={"key_id": key_id})using Microsoft.Extensions.Caching.Distributed;
using System.Text.Json;
using StackExchange.Redis;
// ============================================================================
// Cached Key Lookup
// ============================================================================
public class CachedApiKeyService
{
private const int KeyCacheTtlSeconds = 300; // 5 minutes
private readonly IApiKeyRepository _repository;
private readonly IDatabase _redis;
public CachedApiKeyService(IApiKeyRepository repository, IDatabase redis)
{
_repository = repository;
_redis = redis;
}
public async Task<ApiKeyRecord?> GetKeyRecordAsync(string keyId)
{
var cacheKey = $"apikey:{keyId}";
// Check cache first
var cached = await _redis.StringGetAsync(cacheKey);
if (cached.HasValue)
return JsonSerializer.Deserialize<ApiKeyRecord>(cached!);
// Miss: load from database
var record = await _repository.FindByIdAsync(keyId);
if (record != null && !record.RevokedAt.HasValue)
{
// Cache for TTL (don't cache revoked keys)
await _redis.StringSetAsync(
cacheKey,
JsonSerializer.Serialize(record),
TimeSpan.FromSeconds(KeyCacheTtlSeconds)
);
}
return record;
}
// When a key is revoked, invalidate its cache entry immediately
public async Task RevokeAndInvalidateAsync(string keyId, string reason)
{
await _repository.UpdateAsync(keyId, new
{
RevokedAt = DateTime.UtcNow,
RevocationReason = reason,
});
await _redis.KeyDeleteAsync($"apikey:{keyId}");
// Log handled by caller or structured logging middleware
}
}Part 6: Production Checklist
Key Generation
- Use
crypto.randomBytes(32)(256 bits) for key entropy - Encode keys as hex or base64 (URL-safe), not raw binary
- Include an environment prefix (
live_vstest_) to prevent cross-env use - Include a short key ID in the key string for easy lookup without DB
- Store only the hash (bcrypt or Argon2) — never the raw key
HMAC Implementation
- Use
crypto.timingSafeEqual()for all signature comparisons - Verify timestamp within ±5 minutes to prevent replay
- Use nonces (stored in Redis with TTL) within the replay window
- Hash the request body in the canonical request — detect body tampering
- Include the
Content-Typeheader in the signed headers - Choose HMAC-SHA256 as minimum; HMAC-SHA512 for higher security
Key Management
- Keys have explicit
expiresAtdates (even if 1-2 years) - Rotation procedure documented and practiced
- Revocation immediately invalidates cache entries
- Key creation generates an audit log entry
- Keys have minimum permissions (principle of least privilege)
- Service-to-service keys are separate from user-facing keys
Operational
- Rate limiting per key (with sensible per-tier defaults)
- Monitoring for unusual request volumes per key
- Alerts on authentication failure spikes
- Key expiry alerts (30 days, 7 days)
- Runbook for emergency key revocation
- Regular audit: list of all active keys and their last-used dates
Conclusion
API keys and HMAC signatures offer the best simplicity-to-security ratio for most internal service authentication needs. Plain API keys are acceptable for low-risk, well-monitored internal services. HMAC signatures add request integrity and limit replay attacks at the cost of slightly more implementation complexity.
The operational wins are real: no certificate infrastructure, no authorization server, no TLS mutual handshake complexity. A database lookup (cached in Redis) plus a constant-time HMAC comparison is fast, auditable, and easy to understand when something goes wrong.
Use HMAC signatures as your default for service-to-service API calls where mTLS or OAuth 2.0 feels like overkill. Pair with short-lived keys (6-12 month expiry), automated rotation reminders, and Redis-backed rate limiting, and you have a production-grade authentication layer that any engineer on your team can reason about.