HMAC signatures
Deep dive into HMAC: construction, why the double hash, length extension defense, timing attacks, key rotation, and the right way to verify webhooks.
What HMAC Solves
HMAC stands for Hash-based Message Authentication Code. It takes a shared secret K and a message m and produces a fixed-size tag t such that:
- Anyone with K can verify t was produced from m.
- Without K, you cannot produce a valid t for any message you choose.
- Changing even one bit of m produces a completely different t.
Authentication and integrity in one primitive. That is the whole job.
HMAC is symmetric: both parties hold the same key. That makes it faster than asymmetric signatures (microseconds versus milliseconds) but means anyone who can verify can also forge. If the verifier is your own server, that is fine. If the verifier is the public, you need asymmetric signatures.
The Construction and Why It Looks Weird
The formula is HMAC(K, m) = H((K' xor opad) || H((K' xor ipad) || m)).
K' is K padded or hashed to the block size of H. ipad is the byte 0x36 repeated, opad is 0x5c repeated. H is a hash function like SHA-256.
Why the double hash? Because of length extension. With Merkle-Damgard hashes like MD5, SHA-1, SHA-256, if you know H(secret + m) and the length of secret, you can compute H(secret + m + padding + extra) without knowing secret. This breaks naive SHA256(secret + message) constructions. HMAC's nested structure prevents this.
You will never implement this yourself. Use the standard library. Node has crypto.createHmac, Python has hmac.new, Go has crypto/hmac. SHA-3 and BLAKE2 are not vulnerable to length extension and have a simpler keyed-hash mode, but HMAC-SHA256 is the universal default and what every system speaks.
Webhook Verification: The Canonical Use Case
Every webhook provider uses HMAC. Here is the Stripe pattern, which is the cleanest in the industry.
- Stripe and your server share a secret W.
- Stripe sends the event with a header
Stripe-Signature: t=1700000000,v1=abc123.... - The signed payload is
${timestamp}.${request_body}. - v1 is
HMAC-SHA256(W, signed_payload)as hex.
Your verification.
function verify(rawBody: string, sigHeader: string, secret: string): boolean {
const parts = Object.fromEntries(
sigHeader.split(",").map(kv => kv.split("="))
);
const ts = parts.t;
const v1 = parts.v1;
const signed = `${ts}.${rawBody}`;
const expected = crypto
.createHmac("sha256", secret)
.update(signed)
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))) {
return false;
}
if (Date.now() / 1000 - Number(ts) > 300) return false;
return true;
}Three things this code gets right.
- It uses the raw body, not parsed JSON. JSON parsing and re-serialization changes whitespace and ordering and breaks the signature.
- It uses
timingSafeEqual. A plain===short-circuits and leaks tag bytes via timing. - It rejects timestamps older than 5 minutes. Without this, an attacker who captures one valid webhook can replay it forever.
Timing Attacks Are Real
This is the bug everyone makes. Naive equality comparison returns false on the first differing byte. The attacker measures how long the comparison takes. A reject after 1ns means byte 0 was wrong. A reject after 100ns means bytes 0 through 99 matched and byte 100 was wrong. By varying the tag and measuring, the attacker recovers the tag one byte at a time.
This is not theoretical. Side-channel attacks have been demonstrated over the public internet against poorly written authentication. Always use constant-time comparison.
In Node: crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)). Both buffers must be the same length, or it throws. Pre-check the length yourself.
In Python: hmac.compare_digest(a, b).
In Go: hmac.Equal(a, b) or subtle.ConstantTimeCompare(a, b).
Key Rotation
You will need to rotate secrets eventually: someone left the company, a backup leaked, or just hygiene. The trick is overlap.
Stripe lets you have two webhook secrets active at once. You add the new one, update your server to accept either, then update the webhook config to send only the new one, then remove the old one from your server. Zero downtime.
HMAC Versus Plain Hash Versus Asymmetric Signatures
| Primitive | Key model | Speed | Use when |
|---|---|---|---|
| Plain hash SHA256(secret + msg) | Shared | Fast | NEVER. Vulnerable to length extension. |
| HMAC-SHA256 | Shared | Microseconds | Verifier is trusted, you control both sides. |
| Ed25519 signature | Public/private | ~50 microseconds | Public verification, do not want verifier to forge. |
| RSA-2048 signature | Public/private | Sign 1ms, verify 30 microseconds | Legacy interop. |
The rule of thumb: HMAC for webhooks between known systems, asymmetric signatures for VAPID web push, JWT in distributed systems, and TLS certificates.
AWS SigV4: HMAC at Scale
AWS uses a layered HMAC scheme for API request signing. The signing key is derived through four HMAC operations.
kDate = HMAC("AWS4" + secretKey, date)
kRegion = HMAC(kDate, region)
kService = HMAC(kRegion, service)
kSigning = HMAC(kService, "aws4_request")
signature = HMAC(kSigning, stringToSign)
Why the chain? Defense in depth. If a daily signing key leaks, it only works for that date, region, and service. The root secret is never exposed to the signing path.
This is overkill for most apps but illustrates the pattern: derive purpose-scoped subkeys from a root secret with HMAC. The same pattern shows up in HKDF (HMAC-based Key Derivation Function), used in TLS 1.3 and Signal.
Common Pitfalls Beyond Timing
- Signing parsed JSON instead of raw bytes. JSON.stringify in two different languages produces different bytes. Sign the raw request body.
- Using a short key. HMAC keys should be at least the hash output size. For SHA-256, use 32 random bytes.
- Storing the secret in a header or query string. The whole point is that it never leaves the server.
- Not rejecting old timestamps. Without freshness, replay is trivial.
- Treating HMAC as encryption. The message is plain. If you need confidentiality, use AES-GCM (which gives HMAC-style authentication for free, since GCM is AEAD).
Interview Soundbites
- "HMAC is symmetric, microseconds fast, perfect for webhooks where both sides are trusted."
- "Always constant-time compare. Always sign a timestamp and reject stale messages."
- "Never use SHA256(secret + message). Length extension. Use HMAC."
- "For public verification you need asymmetric signatures, not HMAC."
Learn more
- DocsRFC 2104: HMACIETF
- Docs
- Docs
- Docs