Learn how HMAC works, how to sign API requests for webhooks and APIs, and how to verify signatures securely in your backend code.
HMAC (Hash-based Message Authentication Code) is the backbone of API security for webhooks, payment APIs, and any system where you need to prove that a message came from a trusted sender and wasn't tampered with. Stripe, GitHub, Twilio, and AWS all use HMAC signatures.
HMAC combines a secret key with a message and a cryptographic hash function to produce a signature:
HMAC(key, message) = Hash(key XOR opad || Hash(key XOR ipad || message))
In practice, you don't implement this yourself. You use your language's built-in crypto library. The important properties:
| Method | What it proves | Replay safe | Tamper-proof |
|---|---|---|---|
| API Key in header | Identity (key present) | No | No |
| Bearer token (JWT) | Identity + claims | Depends on exp | Yes (signature) |
| HMAC signature | Identity + message integrity | Depends on timestamp | Yes |
| mTLS | Identity (certificate) | Yes | Yes |
HMAC is ideal for webhooks and server-to-server communication where both sides share a secret.
const crypto = require('crypto');
function signRequest(secret, body) {
return crypto
.createHmac('sha256', secret)
.update(body, 'utf8')
.digest('hex');
}
// When sending a webhook
const body = JSON.stringify(payload);
const signature = signRequest(process.env.WEBHOOK_SECRET, body);
await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Signature-256': `sha256=${signature}`,
},
body,
});
Never use === or == to compare signatures. These are vulnerable to timing attacks — an attacker can infer the signature one byte at a time by measuring how long comparison takes.
Always use crypto.timingSafeEqual:
function verifySignature(secret, body, receivedSignature) {
const expected = crypto
.createHmac('sha256', secret)
.update(body, 'utf8')
.digest('hex');
// Remove prefix if present (e.g., 'sha256=')
const clean = receivedSignature.replace(/^sha256=/, '');
// Constant-time comparison
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(clean, 'hex');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
A valid HMAC signature proves the message is authentic, but doesn't prevent someone from resending the same valid request. Add a timestamp:
// Sender
const timestamp = Math.floor(Date.now() / 1000).toString();
const payload = `${timestamp}.${JSON.stringify(body)}`;
const signature = signRequest(secret, payload);
// Send both timestamp and signature
headers['X-Timestamp'] = timestamp;
headers['X-Signature'] = signature;
// Receiver
function verifyWithReplayProtection(secret, body, timestamp, signature) {
// Reject requests older than 5 minutes
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
throw new Error('Request expired');
}
const payload = `${timestamp}.${body}`;
return verifySignature(secret, payload, signature);
}
This is exactly how Stripe's webhook verification works.
// Next.js API route
export async function POST(req) {
const body = await req.text(); // raw body — don't parse JSON first
const sig = req.headers.get('x-hub-signature-256');
const isValid = verifySignature(process.env.GITHUB_WEBHOOK_SECRET, body, sig);
if (!isValid) {
return new Response('Invalid signature', { status: 401 });
}
const event = JSON.parse(body);
// process event...
}
Important: Always read the raw body before JSON parsing. Parsing and re-serializing changes whitespace, breaking the signature.
| Algorithm | Digest size | Status |
|---|---|---|
| HMAC-MD5 | 128 bits | Deprecated — don't use |
| HMAC-SHA1 | 160 bits | Legacy only (still safe for HMAC, but avoid new usage) |
| HMAC-SHA256 | 256 bits | ✅ Recommended |
| HMAC-SHA512 | 512 bits | ✅ Higher security, larger output |
Use HMAC-SHA256 for new implementations. It's the industry standard and supported everywhere.
Use HeoLab's HMAC Generator to quickly generate and verify HMAC signatures without writing code — useful for testing webhook integrations and debugging signature mismatches.