Why API Keys Alone Aren't Enough
Most APIs protect endpoints with a simple API key in the header:
Authorization: Bearer sk_live_abc123xyz
This works—until it doesn't. If that key leaks (logs, browser history, a misconfigured proxy), an attacker can replay any request indefinitely. They can modify the request body, change query parameters, and the server has no way to detect the tampering.
HMAC request signing solves three problems at once:
- Authentication — proves the request came from someone who holds the secret key
- Integrity — any modification to the URL, headers, or body invalidates the signature
- Replay protection — a timestamp window (usually ±5 minutes) makes captured requests useless after expiry
This is how AWS, Stripe, Twilio, and GitHub all secure their webhooks and APIs. In 2026, with AI agents and automated systems making billions of API calls, this pattern is more relevant than ever.
How HMAC Signing Works
The core idea is simple: instead of sending the secret itself, the client uses the secret to sign a canonical string that describes the request. The server independently rebuilds that same string and verifies the signature.
Client Server
| |
| 1. Build canonical request string |
| 2. HMAC-SHA256(secret, canonical) |
| 3. Send request + signature |
|------------------------------------>|
| | 4. Look up secret by API key
| | 5. Rebuild canonical string
| | 6. Compare signatures (timing-safe)
| | 7. Check timestamp freshness
|<------------------------------------|
| 200 OK (or 401 Unauthorized) |
The canonical request string typically includes:
- HTTP method (GET, POST, etc.)
- Request path
- Timestamp (Unix epoch)
- SHA-256 hash of the request body
- Selected headers (optional, for extra security)
If any of these change, the signature changes. The server rejects it.
Setting Up the Project
We'll build a complete implementation: an Express middleware that verifies signatures, plus a client SDK that signs requests.
mkdir hmac-api-signing && cd hmac-api-signing
npm init -y
npm install express
npm install --save-dev @types/node typescript ts-node
No third-party crypto libraries needed — Node.js crypto module handles everything.
Step 1: The Signing Utility
Create a shared utility that both client and server use:
// src/hmac.ts
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
export interface SignatureComponents {
method: string;
path: string;
timestamp: string;
body: string | Buffer;
}
/**
* Compute SHA-256 hash of the request body.
* This ensures body integrity — any modification breaks the signature.
*/
export function hashBody(body: string | Buffer): string {
const content = typeof body === 'string' ? body : body.toString('utf8');
return createHash('sha256').update(content).digest('hex');
}
/**
* Build the canonical string that gets signed.
* Both client and server must produce identical output.
*/
export function buildCanonicalString(components: SignatureComponents): string {
const bodyHash = hashBody(components.body);
return [
components.method.toUpperCase(),
components.path,
components.timestamp,
bodyHash,
].join('\n');
}
/**
* Sign a canonical string with HMAC-SHA256.
*/
export function sign(secret: string, canonical: string): string {
return createHmac('sha256', secret)
.update(canonical)
.digest('hex');
}
/**
* Constant-time comparison to prevent timing attacks.
* Never use === for signature comparison!
*/
export function verifySignature(expected: string, provided: string): boolean {
if (expected.length !== provided.length) {
// Length mismatch — still do a dummy comparison to maintain constant time
timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(expected, 'hex')
);
return false;
}
return timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(provided, 'hex')
);
}
Why timingSafeEqual?
A naïve === comparison short-circuits as soon as it finds a mismatch. An attacker can send thousands of requests with slightly different signatures and measure response times to reverse-engineer the correct value bit by bit. timingSafeEqual always takes the same amount of time regardless of where the mismatch occurs.
Step 2: Express Verification Middleware
// src/middleware/hmac-verify.ts
import { Request, Response, NextFunction } from 'express';
import { buildCanonicalString, sign, verifySignature } from '../hmac';
// In production: fetch these from your database by API key
const API_CREDENTIALS: Record<string, string> = {
'key_prod_abc123': 'secret_xkJ9mP2qR8vN4wL6',
'key_prod_def456': 'secret_yH7nS3tQ1uM5xO9',
};
const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000; // 5 minutes
export function hmacVerify(req: Request, res: Response, next: NextFunction) {
const apiKey = req.headers['x-api-key'] as string;
const signature = req.headers['x-signature'] as string;
const timestamp = req.headers['x-timestamp'] as string;
// 1. Check required headers
if (!apiKey || !signature || !timestamp) {
return res.status(401).json({
error: 'Missing authentication headers',
required: ['x-api-key', 'x-signature', 'x-timestamp'],
});
}
// 2. Validate timestamp to prevent replay attacks
const requestTime = parseInt(timestamp, 10);
const now = Math.floor(Date.now() / 1000);
if (isNaN(requestTime) || Math.abs(now - requestTime) > TIMESTAMP_TOLERANCE_MS / 1000) {
return res.status(401).json({
error: 'Request timestamp expired or invalid',
tolerance: '±5 minutes',
});
}
// 3. Look up the secret for this API key
const secret = API_CREDENTIALS[apiKey];
if (!secret) {
return res.status(401).json({ error: 'Invalid API key' });
}
// 4. Rebuild the canonical string server-side
const bodyString = req.body ? JSON.stringify(req.body) : '';
const canonical = buildCanonicalString({
method: req.method,
path: req.path,
timestamp,
body: bodyString,
});
// 5. Compute expected signature and compare (timing-safe!)
const expectedSig = sign(secret, canonical);
if (!verifySignature(expectedSig, signature)) {
return res.status(401).json({ error: 'Signature verification failed' });
}
// 6. Attach verified identity to request
(req as any).apiKeyId = apiKey;
next();
}
Step 3: The Express Server
// src/server.ts
import express from 'express';
import { hmacVerify } from './middleware/hmac-verify';
const app = express();
app.use(express.json());
// Public endpoint — no signing required
app.get('/health', (_req, res) => {
res.json({ status: 'ok', time: new Date().toISOString() });
});
// Protected endpoints — requires valid HMAC signature
app.use('/api', hmacVerify);
app.get('/api/data', (req, res) => {
res.json({
message: 'Authenticated!',
apiKey: (req as any).apiKeyId,
data: [1, 2, 3],
});
});
app.post('/api/orders', (req, res) => {
res.status(201).json({
message: 'Order created',
receivedBody: req.body,
});
});
app.listen(3000, () => {
console.log('Server running on http://localhost:3000');
});
Step 4: The Client SDK
This is what your API consumers will use. Package it as an npm module or include it in your SDK:
// src/client/ApiClient.ts
import { createHash, createHmac } from 'node:crypto';
interface ApiClientOptions {
baseUrl: string;
apiKey: string;
apiSecret: string;
}
interface RequestOptions {
method?: string;
path: string;
body?: unknown;
query?: Record<string, string>;
}
export class ApiClient {
private baseUrl: string;
private apiKey: string;
private apiSecret: string;
constructor(options: ApiClientOptions) {
this.baseUrl = options.baseUrl.replace(/\/$/, '');
this.apiKey = options.apiKey;
this.apiSecret = options.apiSecret;
}
private hashBody(body: string): string {
return createHash('sha256').update(body).digest('hex');
}
private buildSignature(method: string, path: string, timestamp: string, bodyString: string): string {
const bodyHash = this.hashBody(bodyString);
const canonical = [method.toUpperCase(), path, timestamp, bodyHash].join('\n');
return createHmac('sha256', this.apiSecret).update(canonical).digest('hex');
}
async request<T>(options: RequestOptions): Promise<T> {
const { method = 'GET', path, body, query } = options;
const timestamp = Math.floor(Date.now() / 1000).toString();
const bodyString = body ? JSON.stringify(body) : '';
const signature = this.buildSignature(method, path, timestamp, bodyString);
const url = new URL(this.baseUrl + path);
if (query) {
Object.entries(query).forEach(([k, v]) => url.searchParams.set(k, v));
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-api-key': this.apiKey,
'x-signature': signature,
'x-timestamp': timestamp,
};
const response = await fetch(url.toString(), {
method,
headers,
body: bodyString || undefined,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(`API error ${response.status}: ${JSON.stringify(error)}`);
}
return response.json();
}
// Convenience methods
get<T>(path: string, query?: Record<string, string>) {
return this.request<T>({ method: 'GET', path, query });
}
post<T>(path: string, body: unknown) {
return this.request<T>({ method: 'POST', path, body });
}
put<T>(path: string, body: unknown) {
return this.request<T>({ method: 'PUT', path, body });
}
delete<T>(path: string) {
return this.request<T>({ method: 'DELETE', path });
}
}
Using the Client
// src/client/example.ts
import { ApiClient } from './ApiClient';
const client = new ApiClient({
baseUrl: 'http://localhost:3000',
apiKey: 'key_prod_abc123',
apiSecret: 'secret_xkJ9mP2qR8vN4wL6',
});
// GET request
const data = await client.get('/api/data');
console.log(data);
// { message: 'Authenticated!', apiKey: 'key_prod_abc123', data: [1, 2, 3] }
// POST request — body is automatically signed
const order = await client.post('/api/orders', {
product: 'api-access',
quantity: 1,
amount: 29.99,
});
console.log(order);
Step 5: Testing the Middleware
// src/__tests__/hmac.test.ts
import { describe, it, expect } from 'node:test';
import assert from 'node:assert';
import { buildCanonicalString, sign, verifySignature } from '../hmac';
describe('HMAC signing', () => {
const secret = 'test_secret_key_32chars_minimum!!';
it('signs and verifies a canonical string', () => {
const canonical = buildCanonicalString({
method: 'POST',
path: '/api/orders',
timestamp: '1742860800',
body: JSON.stringify({ product: 'test', amount: 9.99 }),
});
const signature = sign(secret, canonical);
assert.strictEqual(verifySignature(signature, signature), true);
});
it('rejects a tampered body', () => {
const ts = '1742860800';
const original = buildCanonicalString({
method: 'POST', path: '/api/orders', timestamp: ts,
body: JSON.stringify({ amount: 9.99 }),
});
const tampered = buildCanonicalString({
method: 'POST', path: '/api/orders', timestamp: ts,
body: JSON.stringify({ amount: 999.99 }), // attacker inflated the amount!
});
const signature = sign(secret, original);
const tamperedSig = sign(secret, tampered);
assert.strictEqual(verifySignature(signature, tamperedSig), false);
});
it('rejects a path traversal attempt', () => {
const ts = '1742860800';
const original = buildCanonicalString({
method: 'GET', path: '/api/data', timestamp: ts, body: '',
});
const spoofed = buildCanonicalString({
method: 'GET', path: '/api/admin', timestamp: ts, body: '',
});
const sig = sign(secret, original);
assert.strictEqual(verifySignature(sig, sign(secret, spoofed)), false);
});
});
Run with Node.js built-in test runner (Node.js 22+):
node --test src/__tests__/hmac.test.ts
Handling Edge Cases in Production
1. Body Parsing Order Matters
Express must parse the body before the HMAC middleware reads it. And you must ensure the body is serialized consistently:
// ❌ Wrong: body might be undefined or in wrong format
app.use('/api', hmacVerify);
app.use(express.json());
// ✅ Correct: parse first, then verify
app.use(express.json());
app.use('/api', hmacVerify);
Also, always use JSON.stringify(req.body) — not a raw buffer — so both client and server produce identical canonical strings.
2. Clock Skew Between Clients
A 5-minute tolerance (±300 seconds) is standard. For high-security endpoints, reduce to 60 seconds. For clients in constrained environments (IoT, embedded), you can extend to 10 minutes.
// For financial APIs: tighter window
const TOLERANCE_SECONDS = 60;
// For IoT devices with clock drift: wider window
const TOLERANCE_SECONDS = 600;
3. Storing API Secrets
Never store raw secrets. Use a database with encrypted columns:
// Use bcrypt or AES-256-GCM to encrypt secrets at rest
import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
const ENCRYPTION_KEY = Buffer.from(process.env.SECRET_ENCRYPTION_KEY!, 'hex'); // 32 bytes
function encryptSecret(secret: string): string {
const iv = randomBytes(16);
const cipher = createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
const encrypted = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, encrypted]).toString('base64');
}
function decryptSecret(encrypted: string): string {
const data = Buffer.from(encrypted, 'base64');
const iv = data.slice(0, 16);
const tag = data.slice(16, 32);
const ciphertext = data.slice(32);
const decipher = createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
decipher.setAuthTag(tag);
return decipher.update(ciphertext) + decipher.final('utf8');
}
4. Preventing Replay Attacks with a Nonce Cache
Timestamp checking alone doesn't prevent a captured request from being replayed within the 5-minute window. For critical endpoints (payments, mutations), maintain a nonce cache:
// Use Redis for distributed deployments
import { createClient } from 'redis';
const redis = createClient();
async function checkAndStoreNonce(nonce: string, ttlSeconds = 300): Promise<boolean> {
const key = `nonce:${nonce}`;
// SET with NX (only set if not exists) + EX (expire after TTL)
const result = await redis.set(key, '1', { NX: true, EX: ttlSeconds });
return result === 'OK'; // false means nonce already used
}
// In middleware:
const nonce = req.headers['x-nonce'] as string;
if (nonce) {
const isNew = await checkAndStoreNonce(nonce);
if (!isNew) {
return res.status(401).json({ error: 'Nonce already used (replay attack detected)' });
}
}
Comparing HMAC Signing vs Other Auth Methods
| Method | Replay Protection | Body Integrity | Stateless | Use Case |
|---|---|---|---|---|
| API Key only | ❌ | ❌ | ✅ | Simple internal APIs |
| Bearer JWT | ❌ (until expiry) | ❌ | ✅ | User auth, short-lived |
| HMAC Signing | ✅ | ✅ | ✅ | Server-to-server, payments |
| mTLS | ✅ | ✅ | ✅ | High-security, complex setup |
| OAuth 2.0 PKCE | ✅ | ❌ | ✅ | User-delegated access |
HMAC signing hits the sweet spot for machine-to-machine API communication: it's stateless (no token storage needed), verifiable without network calls, and protects against both eavesdropping and tampering.
Real-World Usage: How Major APIs Do It
Stripe signs webhook payloads with HMAC-SHA256 using the Stripe-Signature header, which includes a timestamp to prevent replays:
Stripe-Signature: t=1742860800,v1=abc123...
AWS Signature Version 4 builds a canonical request with method, URI, query string, headers, and body hash — all signed with HMAC-SHA256.
GitHub signs webhook bodies with X-Hub-Signature-256: sha256=abc123... using a shared secret.
Your implementation follows the same battle-tested pattern.
Putting It All Together
Here's the minimal production checklist:
- ✅ Use
HMAC-SHA256(not HMAC-MD5 or HMAC-SHA1) - ✅ Include method + path + timestamp + body hash in canonical string
- ✅ Use
timingSafeEqualfor comparison — never=== - ✅ Reject requests older than ±5 minutes
- ✅ Add nonce deduplication for mutation endpoints (POST/PUT/DELETE)
- ✅ Encrypt secrets at rest with AES-256-GCM
- ✅ Log auth failures (but not the invalid signatures themselves)
HMAC signing is a thin layer that costs microseconds per request but eliminates entire classes of API abuse. For any server-to-server integration — especially anything touching payments, user data, or critical operations — it's the right default in 2026.
Try It with the 1xAPI SDK
If you're building APIs on 1xAPI, HMAC signing middleware like the one above can be dropped directly into any Express or Hono server. The same pattern works with Bun's built-in Bun.serve() and Deno's Deno.serve() — the node:crypto module is compatible across all three runtimes.
Check out the 1xAPI documentation for more production-ready API patterns.
Top comments (0)