security
How HMAC Works (and Why hash(key + message) Is Broken)
A developer-friendly explainer of HMAC — why a plain hash can't authenticate a message, how the length-extension attack breaks hash(key||message), what the 0x36/0x5c inner and outer pads actually do, and how to verify signatures without leaking your secret through timing.
You need to prove a message came from someone holding a shared secret — a webhook payload, an API request, a signed cookie. The obvious move is to hash the secret together with the message: sha256(secret + message). That obvious construction is broken, and the attack that breaks it doesn’t even require touching the secret. HMAC exists because of that break. Here’s the problem, the attack, and why HMAC’s odd-looking double-hash design fixes it.
The problem: integrity plus authenticity
A plain hash gives you integrity: if one byte of a file changes, the SHA-256 digest changes completely. But integrity alone is worthless against an active attacker, because a hash has no secret in it. Anyone who alters the message can simply recompute the hash of the altered message and send that along. The digest matches; you learn nothing.
What you want is a MAC — a message authentication code: a tag that only someone holding the shared secret key could have produced. The receiver, holding the same key, recomputes the tag and compares. If it matches, the message is intact and it came from a key-holder.
So why not just prepend the key?
tag = SHA-256(key || message)
Because for the hash functions you’d actually reach for — MD5, SHA-1, SHA-256, SHA-512 — this is forgeable.
The length-extension attack, actually explained
MD5, SHA-1, and the SHA-2 family are Merkle–Damgård constructions. They don’t hash a message in one gulp; they chew through it in fixed-size blocks (64 bytes for SHA-256), carrying an internal state from block to block:
state₀ = fixed IV
state₁ = compress(state₀, block₁)
state₂ = compress(state₁, block₂)
...
digest = stateₙ ← the final state IS the output
That last line is the whole vulnerability. The digest isn’t a summary that hides the internal state — it is the internal state after the final block. So if I hand you a SHA-256 digest, I’ve handed you a perfect snapshot of the hash function mid-run, ready to continue.
Now suppose a server authenticates requests with tag = SHA-256(key || message), and an attacker intercepts one signed message:
message = "user=alice&amount=5"
tag = SHA-256(key || "user=alice&amount=5")
The attacker doesn’t know the key. But they know the tag — which is the hash’s internal state after processing key || message (plus the padding the hash appended internally). So they can:
- Load the tag back in as the internal state.
- Keep hashing from there, appending whatever they want:
&amount=999999. - Output the new final state as a valid tag.
The result is a correct tag for:
key || "user=alice&amount=5" || <original padding bytes> || "&amount=999999"
The attacker has to include the padding bytes the hash inserted after the original message (a 0x80 byte, zeros, and the message length), which usually means guessing the key’s length — but not its value, and lengths are easy to brute-force. Many parsers happily skip the padding garbage and honor the last amount= they see. The attacker just forged an authenticated message without ever learning the key. Tools like hash_extender automate the whole thing.
A few important footnotes:
- SHA-3 is immune. It’s a sponge construction — the output is squeezed from a larger internal state, so the digest doesn’t reveal the full state.
- SHA-384 and SHA-512/256 are immune too, despite being SHA-2: they truncate the internal state, so the attacker only sees part of the snapshot and can’t resume.
- Flipping the order doesn’t save you.
SHA-256(message || key)kills length extension but inherits a different weakness: any collision in the hash function becomes a forgery, because colliding messages produce identical tags under every key. With MD5 and SHA-1 collisions being practical, that’s a real problem — and it’s fragile design either way.
Rather than remembering which hash + which concatenation order is safe this decade, use the construction designed for the job.
The HMAC construction
HMAC (RFC 2104, from 1996 — it predates the public discovery of most of these gotchas and survived them all) wraps any hash function like this:
HMAC(K, m) = H( (K′ ⊕ opad) || H( (K′ ⊕ ipad) || m ) )
Unpacking that:
| Piece | What it is |
|---|---|
H | The underlying hash (SHA-256, SHA-512, …) |
B | The hash’s block size — 64 bytes for SHA-256 |
K′ | The key, normalized to exactly B bytes (rules below) |
ipad | The byte 0x36, repeated B times |
opad | The byte 0x5c, repeated B times |
Two hashes, inner and outer:
- Inner: XOR the key with
0x36bytes, prepend it to the message, hash. The key-derived block goes first, so the attacker can’t prepend anything, and the secret is mixed into the state before the message ever arrives. - Outer: XOR the key with
0x5cbytes, prepend it to the inner digest, hash again. This is the length-extension killer: the value an attacker would want to extend (the inner hash) never leaves the building. What’s published is the outer hash — a keyed hash of a digest, and resuming it buys you nothing because you can’t push more message bytes into the inner computation.
The key-normalization rules, straight from RFC 2104:
- Key shorter than
Bbytes: pad with zero bytes up toB. - Key longer than
Bbytes: hash it first, then use the digest (zero-padded toB) as the key. This surprises people: with HMAC-SHA-256, a 65-byte key is first crushed to 32 bytes — so a very long key and its SHA-256 digest produce identical HMACs. - Recommended minimum key length: at least
Lbytes, the hash’s output length (32 for SHA-256). Shorter keys are, in the RFC’s words, strongly discouraged.
Why 0x36 and 0x5c specifically? They differ in several bits, so the inner and outer keys are far apart — effectively two independent keys derived from one, which is what the security proof leans on. HMAC’s guarantees hold even under some weaknesses in the underlying hash, which is why HMAC-MD5 wasn’t immediately broken when MD5’s collisions were (though there’s no reason to use it today — pick HMAC-SHA-256).
Verifying safely: the comparison is part of the crypto
Producing the tag is half the job. The other half is comparing the received tag with the one you computed — and the naive version leaks:
// DON'T
if (receivedSig === expectedSig) { ... }
String equality bails at the first mismatched character. That means comparison time correlates with how many leading characters are correct — a timing oracle. An attacker who can measure response times can lock in the signature one character at a time, turning an impossible brute force into a linear one. It’s a fiddly attack over a network, but it’s cheap to prevent, so every serious implementation does:
// DO: constant-time comparison
crypto.timingSafeEqual(bufferA, bufferB)
GitHub’s webhook docs say it outright: never use a plain ==, use something like secure_compare or crypto.timingSafeEqual. Always compare your computed tag against theirs, in constant time, over equal-length buffers.
Where you meet HMAC every day
You almost never call HMAC by name, but you use it constantly:
| Where | How HMAC shows up |
|---|---|
| JWTs (HS256) | The signature is HMAC-SHA256(base64url(header) + "." + base64url(payload), secret) — the tamper seal on every HS256 token |
| GitHub webhooks | X-Hub-Signature-256: sha256=<hex> — HMAC-SHA256 of the raw request body with your webhook secret |
| Stripe webhooks | Stripe-Signature: t=<timestamp>,v1=<hex> — HMAC-SHA256 over timestamp + "." + body, with a default 5-minute tolerance so replayed deliveries get rejected |
| TOTP codes | Your authenticator app’s 6 digits are a truncated HMAC of the current 30-second time step |
| Signed cookies & URLs | Session frameworks and pre-signed S3-style URLs append an HMAC so clients can hold data they can’t tamper with |
Stripe’s timestamp trick deserves a highlight: by signing timestamp.body instead of just the body, a captured delivery can’t be replayed later — the timestamp is inside the signed data, so an attacker can’t freshen it without breaking the signature. If you’re building webhook receivers, steal that design.
Generate and verify one yourself
Node’s crypto module has everything built in:
import { createHmac, timingSafeEqual } from 'node:crypto';
const secret = 'my-webhook-secret';
const payload = '{"event":"payment.succeeded","amount":4200}';
// Generate (what the sender does)
const signature = createHmac('sha256', secret)
.update(payload)
.digest('hex');
// e.g. "f8c3bf2f68fa96ba07985d5b6d284b8377b45ff97a742079ef7c...";
// Verify (what your receiver should do)
function verify(payload, receivedHex, secret) {
const expected = createHmac('sha256', secret).update(payload).digest();
const received = Buffer.from(receivedHex, 'hex');
// timingSafeEqual throws on length mismatch — check first
return received.length === expected.length
&& timingSafeEqual(received, expected);
}
verify(payload, signature, secret); // true
verify(payload + ' ', signature, secret); // false — one byte changed
Note the length check before timingSafeEqual — it throws on unequal-length buffers rather than returning false. And when verifying webhooks, always HMAC the raw request body bytes, not a re-serialized version: parse-then-stringify reorders keys and changes whitespace, and the signature dies.
To poke at it interactively, the HMAC generator computes HMAC-SHA-1/256/384/512 from any message and key, entirely in your browser. Try it: change one character of the message and watch the digest scramble; then compare against a plain hash of the same input to see that HMAC with an empty-ish key is still nothing like H(key || message). If you’re wiring up webhook verification, the webhook tester gives you a capture URL to inspect exactly which signature headers a service sends, and the JWT decoder shows HMAC’s most famous gig — the HS256 signature — in context.
The rules to actually remember
- A hash alone authenticates nothing — no secret, no authenticity.
- Never build
hash(key || message)yourself. MD5, SHA-1, SHA-256, and SHA-512 all leak their internal state, and length extension turns your tag into a forgery kit. (hash(message || key)trades that for collision fragility — still don’t.) - Use HMAC — it’s in every standard library, it’s fast, and it stays secure even where its underlying hash has cracks.
- Use a real key: at least as long as the hash output (32 random bytes for HMAC-SHA-256), and remember keys longer than the block size get hashed down first.
- Compare in constant time, against the raw request body, with the length checked first.
HMAC is one of those rare pieces of cryptography that’s been sitting unbroken since 1996 precisely because it assumed the hash underneath it might someday wobble. Use it, don’t reinvent it.