By Freddy ·
A plain hash answers one question: has this data changed since the hash was computed. It says nothing at all about who computed it. That distinction sounds academic right up until you have to verify a webhook from a payment provider, and it turns out SHA256(body) matching the value in a header proves precisely nothing, because anyone who can read the request can also recompute that same hash. HMAC exists to close that exact gap, and understanding how it does it explains why so many homegrown signature schemes are quietly broken.
SHA-256 and its relatives are integrity functions: feed them a message, get back a fixed-length digest, and any later change to the message, even one bit, produces a completely different digest. That is genuinely useful for detecting corruption, which is why download pages still publish a SHA-256 checksum next to an installer. But integrity is not authenticity. If an attacker can modify both the message and the accompanying hash before you check it, a plain hash gives you no way to notice, because the attacker just recomputes the hash over their modified message and it matches perfectly. A plain hash protects against accidental corruption. It does nothing against a party who can freely recompute the function, which includes anyone on the network path and, in a lot of real designs, anyone reading your API documentation.
The obvious next idea is to fold a shared secret into the hash: instead of hash(message), compute hash(secret + message), so an attacker who does not know the secret cannot produce a matching digest for a tampered message. This looks reasonable and it is exactly wrong for any hash built on the Merkle-Damgård construction, which includes MD5, SHA-1, and SHA-256. Those hash functions process input in fixed-size blocks and expose their entire internal state as the final output, which means an attacker who has hash(secret + message) and knows the length of secret can compute a valid hash for secret + message + attacker_data without ever learning the secret itself. This is a length extension attack, and it is a real, exploitable bug class, not a theoretical footnote. Several production APIs have shipped exactly this hash(secret + message) pattern for request signing and had to walk it back after someone demonstrated forged, extended requests that still passed verification.
HMAC does not just concatenate the secret and the message. It nests the hash function around the key in a specific, deliberate structure: HMAC(K, m) = H((K' XOR opad) || H((K' XOR ipad) || m)), where K' is the key padded or hashed down to the hash function's block size, and opad/ipad are fixed constants defined by the standard. The inner hash commits to the key and the message together, but then that entire result gets hashed again, wrapped inside an outer application of the key. An attacker who intercepts an HMAC output does not get direct access to the hash function's internal state the way they do with a naive prefix construction, because the outer hash operation sits between them and it. That structural difference, not just “adding a secret,” is what makes HMAC resistant to length extension in a way that hash(secret + message) never was.
Getting the construction right is only half the job. Comparing a computed HMAC against the one you received with a normal === or string.Equals check leaks timing information, because most language runtimes short-circuit string comparison at the first mismatched byte. An attacker who can measure response time precisely enough can, in principle, recover a valid signature one byte at a time by watching which guesses take marginally longer to reject. The fix is a constant-time comparison function, such as Node's crypto.timingSafeEqual or Python's hmac.compare_digest, which always examines every byte regardless of where the first mismatch occurs. This is a narrower, harder-to-exploit attack than a broken construction, but it is a real category of bug in production signature verification code, and it only exists because HMAC output is compared as data rather than checked by a purpose-built function that already handles this.
The most common place developers run into HMAC directly is webhook verification: Stripe, GitHub, and most other providers that push events to your endpoint sign the request body with a shared secret using HMAC-SHA256 and send the signature in a header, and your job is to recompute it over the raw body and compare. It also sits underneath the HS256 algorithm in a JWT, where the token's signature is an HMAC over the header and payload using a secret only the issuer holds, which is exactly why decoding a JWT and verifying one are different operations: decoding just reads the base64, verifying recomputes the HMAC and checks it matches. Message queues, pre-signed URLs, and API request-signing schemes lean on the same primitive for the same reason, proving the request came from someone who holds the shared secret, not just that the bytes are internally consistent.
If you need to prove a file has not been corrupted in transit, a plain hash is the right and sufficient tool. The moment the requirement becomes proving who produced a piece of data, whether that is a webhook payload, an API request, or a token, a plain hash is the wrong primitive no matter how it gets dressed up, and folding in a secret by hand is not a safe substitute for HMAC. Use a standard library's HMAC implementation, verify with a constant-time comparison function instead of a plain equality check, and treat any codebase that does hash(secret + payload) for signature verification as a bug to fix, not a style choice.