01What is HMAC?
A hash-based message authentication code (HMAC) is a short tag computed from a message and a secret key with a cryptographic hash function such as SHA-256. Anyone holding the same key can recompute the tag and compare: if it matches, the message came from a key holder and was not altered on the way. Without the key, nobody can forge a valid tag or change the message undetected.
The construction, defined in RFC 2104, hashes the message twice with two padded versions of the key: HMAC(K, m) = H((K ⊕ opad) ∥ H((K ⊕ ipad) ∥ m)). The nesting is what makes HMAC safe where a naive hash of key plus message is not: it resists the length-extension attacks that affect SHA-256 used on its own. With SHA-256 the tag is 32 bytes, commonly sent as 64 lowercase hex characters or as Base64.
HMAC is the general primitive, and many security mechanisms in messaging are HMACs with a particular key and input. Meta's X-Hub-Signature-256 webhook header is an HMAC-SHA256 of the raw body keyed with the App Secret, and App Secret Proof (appsecret_proof) is an HMAC-SHA256 of an access token with the same key. Payment gateways, SMS providers and your own services use the idea to sign callbacks. Because both sides share one key, HMAC proves authenticity between two parties but, unlike a digital signature, cannot prove to a third party which of them made the tag.
Key takeaways
- HMAC combines a secret key with a hash function to produce a tag that only key holders can create or check.
- HMAC-SHA256 outputs 32 bytes, usually sent as 64 hex characters or as Base64.
- A plain hash proves nothing about the sender; an HMAC proves the sender knew the shared secret.
- Always compute over the exact raw bytes received and compare tags in constant time.
- HMAC does not stop replays on its own: sign a timestamp or nonce and reject stale or repeated messages.
02 · How it works, step by step
How does HMAC work?
- Step 1
Share a secret key
Both parties hold the same random key (at least 32 bytes for HMAC-SHA256), exchanged out of band and kept with your Secrets Management tooling.
- Step 2
The sender computes the tag
It runs HMAC-SHA256 over the exact bytes it sends, often a timestamp joined to the body, and puts the tag in a header such as
X-SignatureorX-Hub-Signature-256. - Step 3
The receiver recomputes
Using its copy of the key, it computes the HMAC over the raw bytes received, before any JSON parsing or re-serialising.
- Step 4
Compare in constant time
Use
crypto.timingSafeEqual(Node.js) orhmac.compare_digest(Python) so the response time does not reveal how many leading characters matched. - Step 5
Check freshness
If the signed data includes a timestamp, reject messages older than a few minutes and remember recent IDs, so a captured request cannot be replayed later.
03HMAC in code
import crypto from "node:crypto";const key = Buffer.from(process.env.CALLBACK_SIGNING_KEY, "base64"); // 32+ random bytes// Sender: sign "timestamp.body"export function sign(rawBody, ts = Math.floor(Date.now() / 1000)) {const tag = crypto.createHmac("sha256", key).update(`${ts}.${rawBody}`).digest("hex");return { "X-Timestamp": String(ts), "X-Signature": `sha256=${tag}` };}// Receiver: reject stale requests, recompute over the raw bytes, compare in constant timeexport function verify(rawBody, headers, maxAgeSec = 300) {const ts = Number(headers["x-timestamp"]);if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > maxAgeSec) return false;const expected = Buffer.from("sha256=" + crypto.createHmac("sha256", key).update(`${ts}.${rawBody}`).digest("hex"),);const given = Buffer.from(headers["x-signature"] ?? "");return given.length === expected.length && crypto.timingSafeEqual(given, expected);}
Signing the timestamp with the body means an attacker cannot refresh an old request without breaking the tag.
04HMAC: key facts and limits
- Standards
- RFC 2104 (1997); NIST FIPS 198-1
- HMAC-SHA256 tag
- 32 bytes = 64 hex characters
- Recommended key
- At least 32 random bytes for HMAC-SHA256
- SHA-256 block size
- 64 bytes; longer keys are hashed first
- Inner and outer pads
- ipad = 0x36, opad = 0x5c, repeated
- Meta's uses
- X-Hub-Signature-256 and appsecret_proof
05 · Common mistakes, and the fix
Common mistakes with HMAC
Hashing parsed and re-serialised JSON
Whitespace, key order and Unicode escaping change when JSON is stringified again, so the tag never matches. Capture the raw body bytes and verify those.
Comparing tags with ==
An ordinary string comparison stops at the first differing character and leaks timing information. Use a constant-time comparison function.
Using a plain hash as a signature
SHA-256(body) can be recomputed by anyone, and SHA-256(secret + body) is open to length extension. Use the HMAC construction, not a home-made one.
Weak or shared keys
A short, guessable or widely shared key can be brute-forced offline or reused. Generate 32+ random bytes per integration and rotate them.
Assuming a valid tag means a fresh request
An attacker can resend a captured, correctly signed request. Sign a timestamp or nonce and reject old or repeated ones; see Replay Attack.
06Why does HMAC matter?
Webhook and callback URLs are public: anyone who finds yours can POST to it. Without a signature check, a forged "payment succeeded" or "message delivered" event goes straight into your database. HMAC is the cheap, standard way to make every request prove where it came from, at a few microseconds of hashing per request, and getting the details right (raw bytes, constant-time comparison, key handling, freshness) is what separates a real check from a decorative one.
07 · Tips from the bench
Best practices for HMAC
- Handle prefixes such as
sha256=the same way on both sides, or compare the full header string. - During key rotation, accept tags made with either the old or the new key until every sender has switched.
- Log signature failures with the request ID, never with the key or the full tag.
- Prefer HMAC-SHA256 over HMAC-SHA1 for new integrations.
08Example: A replayed callback that bounced
A Jaipur travel agency's booking engine receives payment callbacks and then sends a WhatsApp confirmation template. Each callback carries X-Timestamp and an HMAC-SHA256 of timestamp.body, keyed with a secret shared with the payment gateway. During a security test, a tester replays last week's genuine callback with its valid signature; the verifier rejects it because the timestamp is days old. A second test changes amount_paise from 149900 to 14990 and fails the HMAC comparison outright. Only fresh, untouched callbacks ever trigger a confirmation.