English
HMAC shows up in webhook payloads, API request signing and session tokens — and it gets confused with two other things that behave differently: plain hashing and digital signatures. This guide separates the three with examples you can reproduce yourself, then covers the failure modes that actually break integrations: signature mismatches, key handling and the password-hashing trap.
What HMAC adds over a plain hash
A plain hash like SHA-256 is a one-way fingerprint of a message. Anyone can compute it; knowing it proves nothing about who computed it.
HMAC — Hash-based Message Authentication Code — is the same hash computation run with a secret key folded in. The result binds the message to whoever holds the key. Reproduce it yourself:
key = "key"
msg = "hello"
HMAC-SHA256(key, msg) =
9307b3b915efb5171ff14d8cb55fbcc798c6c0ef1456d66ded1a6aa723a58b7b1
2
3
4
5
2
3
4
5
You can verify this with openssl dgst -sha256 -hmac "key" or one line of Node: crypto.createHmac('sha256', 'key').update('hello').digest('hex'). The value matches because HMAC-SHA256 is a standard, not an implementation detail.
Two properties follow, both checkable in the same run. Change the key to "secret1" or "secret2" and the output changes completely — the key is not a salt, it's load-bearing. And compute a plain SHA-256 of the same message without any key: you get a different fingerprint entirely, which is why "just hash it with SHA-256" doesn't authenticate anything — a plain hash is something anyone can recompute.
HMAC is not a digital signature
The two get conflated because both sound like "prove authenticity". They differ in who can create the proof.
With HMAC, both sides hold the same secret. The server can verify a client's message because it could have produced the same MAC itself. That means HMAC proves the message came from someone who has the key and that it wasn't modified in transit — but it does not prove which holder sent it, and it gives no non-repudiation: either party could have generated the MAC, so neither can be held to it later.
A digital signature uses an asymmetric key pair. The signer holds a private key; everyone else verifies with the public one. Only the private-key holder can produce a valid signature, and anyone can verify it — that's what gives you non-repudiation and third-party verifiability.
The practical mapping: HMAC for first-party machine-to-machine traffic where both endpoints share one trust domain (webhooks from a provider you trust, internal service calls); digital signatures where third parties must verify origin (JWT RS256/ES256 variants, certificate chains, signed releases). If your "signature" scheme has both sides computing with the same secret, it's an HMAC — name it accordingly and pick its threat model.
Why HMAC does not belong in password storage
The trap most often repeated in tutorials: "hash passwords with HMAC-SHA256". Don't.
Password hashing exists to survive a database dump. HMAC-SHA256 is fast — deliberately so, it's built on a hash designed for speed. An attacker with your database and your code (which is how real breaches work) can brute-force fast hashes at enormous rates. The countermeasure is a hashing scheme designed to be slow and memory-hard, with a per-password salt baked in: bcrypt, scrypt or argon2.
The honest split: HMAC authenticates messages between parties that already hold a shared secret; password hashing protects a low-entropy secret (a human password) against offline attack. Different threat models, different tools. Using HMAC for password storage inherits the speed of the underlying hash as a liability, not a feature.
The failure mode that actually breaks integrations: signature mismatch
The most common HMAC support question is "why doesn't my signature match the provider's?" — in webhook verification. The recurring causes, roughly in order of frequency:
Serialization drift. The provider signed the raw request body as bytes; you re-serialized the parsed object and the JSON came out with different spacing or key order. Sign the raw bytes, compare against the raw bytes.
Line endings and trailing whitespace. A trailing newline added by a shell pipe or an editor silently changes the MAC. The provider's own HMAC test vectors will fail this way if you computed yours over a CRLF-normalized body.
Encoding of the output. Hex and Base64 represent the same MAC. Comparing hex against base64 fails even when the bytes match — normalize both sides to the same encoding before comparing, or better, compare the raw bytes.
Algorithm or key mix-ups. HMAC-SHA1 and HMAC-SHA256 with the same key produce different outputs; a key pasted with a trailing newline does too. The fix is boring and effective: print both values, diff them character by character, and check the inputs match exactly.
You can reproduce every one of these locally against the HMAC values above — the same-message-different-key property is what makes each failure mode produce a completely different MAC, which is also what makes mismatches diagnosable rather than mysterious.
Computing and verifying one yourself
The online HMAC generator takes a message and a secret, offers MD5, RIPEMD160, SHA-1, SHA-3, SHA-224/256/384/512 and outputs hex, Base64 or binary — useful when you need a reference MAC to diff against your code's output, with nothing leaving the browser.
For the verification side of a webhook, the code is small but the order matters:
js
import crypto from 'node:crypto';
const expected = crypto
.createHmac('sha256', SECRET)
.update(rawBody) // the raw request body, not a re-serialization
.digest('hex');
const valid = crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(received),
);1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
timingSafeEqual matters: comparing MACs with === leaks timing information about how far the match got. And rawBody means what arrived on the wire — if your framework parsed and re-stringified the JSON before you saw it, the bytes no longer match what the provider signed.
The short version
HMAC proves a message came from a key-holder and wasn't altered — both of which you can check locally, because the math is standard and the values above are reproducible. It doesn't identify which key-holder sent something, it isn't a substitute for asymmetric signatures where third parties must verify, and it isn't a password-hashing scheme. For the day-to-day failure, diff your inputs before your outputs: most signature mismatches are serialization and line endings, not broken crypto.