What HMAC adds over a plain hash
A bare hash (SHA-256 of a message) proves only integrity — that the bytes weren’t changed — but anyone can recompute it, so it proves nothing about who produced it. HMAC mixes a secret key into the hash, so reproducing the signature requires both the message and the key. That single addition buys you authenticity: a recipient who shares the secret can confirm the message came from someone who holds it, not an impostor. This is why HMAC, not a plain hash, underpins API authentication and webhook verification.
The request-signing pattern
Most signed-API schemes (AWS SigV4 is the canonical example) follow the same shape, and understanding it explains a lot of “why doesn’t my signature match” pain:
- Build a canonical string from the request — method, path, sorted query, key headers, and a hash of the body — in an exact, agreed format.
- HMAC that canonical string with the shared secret.
- Send the result in a header; the server rebuilds the canonical string identically and recomputes.
The fragility is in step 1: the two sides must serialize byte-for-byte identically. A re-ordered header, a re-pretty-printed JSON body, or a stray trailing newline produces a totally different HMAC. When signatures mismatch, the bug is almost always a canonicalization difference, not the HMAC itself.
Choosing the output encoding
The same HMAC bytes can be represented several ways, and matching the expected format is essential:
| Encoding | SHA-256 length | Use when |
|---|---|---|
| Hex | 64 chars | Human-readable, most webhook headers |
| Base64 | 44 chars | Compact, general transport |
| Base64URL | 43 chars (no padding) | JWTs, URL parameters |
A mismatched encoding is one of the most common reasons a correct HMAC appears “wrong” — confirm whether the other system expects hex or Base64 before debugging anything deeper.
Verify in constant time
When checking a received signature, never use a normal string ==. Ordinary comparison short-circuits at the first differing byte, so the time it takes leaks how many leading bytes matched — enough, in principle, to forge a signature byte by byte. Use a timing-safe comparison (crypto.timingSafeEqual in Node, hmac.compare_digest in Python). Generation here runs entirely in your browser via the Web Crypto API, so messages and secrets stay local — handy for reproducing exactly what a provider expects and pinning down which side’s canonicalization is off.