Engineer's toolkit
HMAC and webhook signing
A keyed hash proves a message came from a key holder and was not changed. It protects webhooks, signed URLs and TOTP codes.
A message authentication code (MAC) is a hash that also takes a secret key. Only a key holder can produce a valid tag. If the tag checks out, the message came from a key holder and wasn't changed. HMAC builds a MAC from any hash function. HMAC-SHA256 is the common choice.
The big idea
A plain hash isn't enough. Anyone can recompute it, so a changed message just comes with a new hash. A MAC adds a key, so an attacker who changes the message can't produce a matching tag.
Why not sha256(secret + message)? Because of length extension, covered in hash functions. HMAC hashes the key and the message twice, with the key mixed in each time. That double wrapping blocks length extension.
Concept
The shared key is the catch. Sender and verifier both hold it, so the verifier could have made the tag too. A MAC proves the message is genuine, but not which side made it. For that, you need a signature.
Walking through it
Here is a webhook verifier, in the style payment providers use for their callbacks. The header carries a timestamp t and a tag v1. The tag covers the timestamp and the raw body. The demo signs a body, then verifies it and a tampered copy.
import hmac, hashlib, time
def verify_webhook(secret: bytes, raw_body: bytes, header: str, tolerance_s: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
ts = int(parts["t"])
if abs(time.time() - ts) > tolerance_s:
return False # stale: blocks replay
signed = f"{ts}.".encode() + raw_body # sign the raw bytes
expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"]) # constant-time compare
secret, body = b"whsec_test", b'{"id": "evt_1", "amount": 4200}'
ts = int(time.time())
sig = hmac.new(secret, f"{ts}.".encode() + body, hashlib.sha256).hexdigest()
header = f"t={ts},v1={sig}"
print(verify_webhook(secret, body, header))
print(verify_webhook(secret, body.replace(b"4200", b"1"), header))Output:
True
FalseThree details in that function are where real bugs live:
- Raw body. If you parse the JSON and re-serialize it, key order or whitespace can change, and the signature never matches. Engineers then disable verification to "fix" it. Verify the bytes you received.
- Timestamp in the signed data. Without it, one captured webhook can be replayed forever. The tolerance window limits replays.
- Constant-time comparison.
==stops at the first differing byte, so response timing leaks how many leading bytes were right. Usehmac.compare_digest,crypto.timingSafeEqualorsubtle.ConstantTimeCompare.
You'll also meet HMAC in signed S3 presigned URLs (SigV4 is a chain of HMACs), signed session cookies, HS256 JWTs, and the codes in MFA and passkeys.
Common mistakes
- Verifying a re-serialized JSON body instead of the raw bytes.
- Leaving the timestamp out of the signed data.
- Comparing tags with
==.
Check yourself
- Why can't an HMAC prove which side made a message? Answer: both sides hold the key, so the verifier could have computed the same tag.
- Why does re-serializing the JSON break a genuine webhook? Answer: key order and whitespace change, so the bytes differ from what the sender signed.
Key takeaways
- A MAC is a hash with a secret key. A matching tag means the message is genuine and unchanged.
- HMAC wraps the hash twice, which blocks length extension. Always use it instead of
hash(secret + message). - Sign the raw body, include a timestamp, and compare tags in constant time.
- Use a signature instead of an HMAC when verifiers must not be able to sign.
Further learning
Next, see how the same key-based idea protects the data itself in AEAD and envelope encryption.
Related
Cryptography for engineers
The few building blocks you need for everyday security decisions, which question each one answers, and where teams go wrong. Start here for the map.
Hash functions
A hash turns any input into a fixed-size fingerprint. A secure hash is hard to reverse and hard to collide, and MD5 and SHA-1 no longer meet that bar.
MFA and passkeys
How TOTP codes work, why SMS is the weakest second factor, and how passkeys resist phishing by binding each login to a single website.