You hash a file, you get a fingerprint. You hash a file with a secret key mixed in, you get proof. That second thing is an HMAC — hash-based message authentication code — and it's quietly running under Stripe webhooks, GitHub push events, AWS API calls, and every JWT signed with HS256. This guide covers what the key actually does, why the obvious way of combining key and message is broken, and how to verify real-world signatures without shipping the classic timing-attack bug.
A cryptographic hash like SHA-256 is deterministic and public. Same input, same 64-hex-character output, and anyone can compute it. That's perfect for checksums — "is this file the file?" — and useless for authenticity, because an attacker rewriting the message can just as easily recompute the digest of their rewrite.
HMAC threads a shared secret through the computation. The output now depends on two inputs: the message and the key. An attacker can rewrite the message, but without the key they cannot produce the digest that a verifier will accept. Verification becomes: "do I hold the same key the sender holds, and does the digest match the bytes I received?"
| Plain hash (SHA-256) | HMAC-SHA256 | |
|---|---|---|
| Inputs | Message | Message + secret key |
| Computable by | Anyone | Only key holders |
| Proves | Data integrity | Integrity + authenticity |
| Typical use | File checksums, dedupe, mining | Webhook signatures, API auth, JWTs |
The intuitive construction — SHA256(key + message) — is broken, and understanding why is the fastest way to respect HMAC's design. SHA-256 and its relatives process messages in blocks and expose their internal state as the final digest. That means someone who sees H(key + "authorize $10") can compute a valid digest for H(key + "authorize $10" + attacker_suffix) without knowing the key — the published digest already contains the keyed state, and the hash happily continues from there. This is the length-extension attack, and it's not theoretical; it broke real API authentication schemes built on MD5 and SHA-1.
HMAC's fix (Bellare, Canetti, and Krawczyk, 1996) nests two hashes with two keys derived from one: HMAC(K, m) = H((K' ⊕ opad) ‖ H((K' ⊕ ipad) ‖ m)), where ipad and opad are fixed padding bytes and K' is the key padded to a block. The inner hash's output is squeezed through the full outer hash, so no intermediate keyed state ever leaks. The construction carries a security proof relative to the underlying hash — one reason it survived even as its building blocks aged.
Stripe-Signature header, GitHub's X-Hub-Signature-256, Shopify, Slack, Twilio — an HMAC-SHA256 over the raw body with a per-endpoint secret, so your server can reject forged calls at the door.SHA-1/256/384/512, hex or Base64, computed by your browser's native WebCrypto — test vectors preloaded.
HMAC Generator →The pattern is identical across services; only the header and payload format change. Stripe, the canonical example, signs timestamp + "." + raw_body:
// Node.js (Express) — Stripe-style
const crypto = require('crypto');
app.post('/webhook', express.raw({type: 'application/json'}),
(req, res) => {
const parts = Object.fromEntries(
req.headers['stripe-signature'].split(',')
.map(p => p.split('=')));
// parts = { t: '1735689600', v1: 'hex...' }
const payload = parts.t + '.' + req.body.toString(); // raw bytes!
const expected = crypto.createHmac('sha256', process.env.WH_SECRET)
.update(payload).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(400).send('Bad signature');
}
res.json({received: true});
});
# Python (FastAPI) — same shape
import hmac, hashlib
expected = hmac.new(SECRET.encode(),
f"{ts}.{raw_body}".encode(),
hashlib.sha256).hexdigest()
ok = hmac.compare_digest(expected, received_hex)
Three bugs account for nearly every failed verification: hashing re-serialized JSON instead of the raw request bytes (key order and whitespace change the digest), forgetting the timestamp prefix when the spec includes one, and comparing strings with ===/== instead of the constant-time compare both snippets use. Plain equality leaks information through timing — each mismatched character exits a few nanoseconds earlier — and against a patient attacker, that's a real channel.
Default to HMAC-SHA256. SHA-1's collision weakness doesn't directly break HMAC-SHA1, but it buys nothing, so it survives only in legacy protocols. SHA-384 and SHA-512 matter where you're matching an existing spec (HS512 tokens, bank APIs), not for extra security margin — output length isn't the bottleneck.
Keys should be random bytes at least as long as the hash output: 32 bytes for SHA-256. Derive them from a CSPRNG (crypto.randomBytes(32), secrets.token_bytes(32)), store them in a secrets manager, and rotate them if there's any chance of exposure. Never derive signing keys from passwords — offline guessing then beats every property the MAC was supposed to provide.
To sanity-check your own implementation, test against published known-answer vectors. HMAC-SHA256 with key key over The quick brown fox jumps over the lazy dog must give f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8 — the HMAC generator ships with that vector preloaded, and RFC 4231 publishes a fuller set for every digest size.
No. A hash like SHA-256 is a one-way fingerprint anyone can compute from the message. An HMAC folds a secret key into that computation, so the output depends on both the message and a key only the sender and receiver share. That key is what makes it an authentication code rather than a checksum.
Naive constructions like SHA-256(key + message) fall to length-extension attacks: because of how Merkle-Damgård hashes process blocks, an attacker who sees one valid digest can forge a valid digest for the message plus arbitrary suffix data without knowing the key. HMAC's nested construction (hash of an inner hash, with two different padded keys) provably blocks this.
No — there is nothing to decrypt. An HMAC is a fixed-size fingerprint of the message under the key. The only way to 'break' it is to guess the key, and a random 256-bit key makes brute force infeasible. You verify an HMAC by recomputing it, never by decoding it.
Read the signature header the service sends (e.g. Stripe-Signature), rebuild the exact signed payload — often timestamp + '.' + raw request body — compute HMAC-SHA256 with your endpoint secret, and compare in constant time: crypto.timingSafeEqual in Node, hmac.compare_digest in Python. Always verify the raw bytes, not a re-serialized JSON object, since key order and whitespace change the signature.
Match the hash: 32 random bytes for HMAC-SHA256, 48 for SHA-384, 64 for SHA-512. Anything beyond the hash's output size adds no security. Generate keys from a CSPRNG (crypto.randomBytes in Node, secrets.token_bytes in Python) — never from a password, timestamp, or dictionary word.