Chapter 11 — HMAC: proving a message wasn't tampered with
In one sentence
An HMAC is a hash that requires a secret key, so it proves both that the message is unchanged and that it came from someone holding the key.
The problem a plain hash doesn't solve
Alice sends: message = "pay bob 100"
hash = sha256(message)
Mallory intercepts, and simply sends:
message = "pay mallory 9000"
hash = sha256("pay mallory 9000") <-- she can compute it!
Bob checks the hash. It matches. He pays.
A hash proves integrity against accidents, not against attackers — because anyone can recompute it. You need a secret.
The naive fix, and why it fails
BROKEN: tag = sha256(secret || message)
Vulnerable to LENGTH EXTENSION (Chapter 9): given the tag and the length
of the secret, an attacker computes a valid tag for
message || padding || anything_they_want -- without knowing the secret.
This is not theoretical: it's how the Flickr API signature was broken in 2009.
HMAC: the correct construction
HMAC nests the hash twice with two derived keys, which defeats length extension:
HMAC(K, m) = H( (K XOR opad) || H( (K XOR ipad) || m ) )
where ipad = 0x36 repeated, opad = 0x5c repeated
You never need to implement this — every language has it. But knowing why it's nested explains why you must not roll your own.
Alice: tag = HMAC-SHA256(shared_key, "pay bob 100")
sends (message, tag)
Bob: recomputes HMAC-SHA256(shared_key, received_message)
compares to received tag, IN CONSTANT TIME
Mallory: cannot produce a valid tag without the key.
MAC vs Signature — the distinction that matters
HMAC (symmetric) SIGNATURE (asymmetric, Chapter 21)
---------------- ---------------------------------
ONE shared key private key signs, public key verifies
Both parties can create tags only the holder of the private key can sign
FAST (microseconds) slower (milliseconds)
-> integrity + authenticity -> integrity + authenticity + NON-REPUDIATION
Because both sides can create an HMAC, Bob cannot prove to a THIRD PARTY
that Alice sent it -- he could have made it himself.
A signature can be proven to a third party. That's non-repudiation.
Choose HMAC when both parties are you (session tokens, internal APIs, webhooks). Choose signatures when a third party must verify, or when you can't share a key.
Where you'll actually use HMAC
| Use | Detail |
|---|---|
| Session tokens / signed cookies | `value |
| Webhook verification | Stripe, GitHub, etc. sign payloads; you verify before trusting |
| API request signing | AWS SigV4 signs method + path + body + timestamp |
| JWT with HS256 | The signature is an HMAC |
| Key derivation | HKDF is built from HMAC |
| TLS | Record integrity (in non-AEAD modes) and key derivation |
Constant-time comparison — do not skip this
# DANGEROUS -- leaks information through timing
if computed_tag == received_tag: # == returns early on first mismatch
# CORRECT
import hmac
if hmac.compare_digest(computed_tag, received_tag):
A normal == stops at the first differing byte. An attacker who can measure
response times learns how many leading bytes were correct, and can then forge
a tag one byte at a time — a few thousand requests instead of 2^256 guesses.
Use constant-time comparison for every secret comparison: MACs, tokens, API keys, password reset codes, TOTP codes.
Practice (30 min)
cd ~/sec-lab/scratch
python3 - <<'PY'
import hmac, hashlib, time
key = b"shared-secret-key"
def sign(msg: bytes) -> str:
return hmac.new(key, msg, hashlib.sha256).hexdigest()
def verify(msg: bytes, tag: str) -> bool:
return hmac.compare_digest(sign(msg), tag) # CONSTANT TIME
msg = b"pay bob 100"
tag = sign(msg)
print("message:", msg.decode())
print("tag: ", tag)
print("verify original: ", verify(msg, tag))
# Attacker tampers with the message
print("verify tampered: ", verify(b"pay mallory 9000", tag))
# Attacker tries to forge a tag without the key -- they can't
print("forged tag ok?: ", verify(b"pay mallory 9000",
hmac.new(b"guessed-key", b"pay mallory 9000", hashlib.sha256).hexdigest()))
PY
Now demonstrate the timing leak yourself — this is the exercise that makes constant-time comparison stick:
python3 - <<'PY'
import time, statistics
SECRET = "a1b2c3d4e5f6a7b8"
def unsafe_compare(a, b): # early-return, like ==
if len(a) != len(b): return False
for x, y in zip(a, b):
if x != y: return False
time.sleep(0.0005) # exaggerate real per-byte work
return True
def timed(guess, n=15):
ts = []
for _ in range(n):
t = time.perf_counter(); unsafe_compare(SECRET, guess)
ts.append(time.perf_counter() - t)
return statistics.median(ts)
# Recover the secret one character at a time by timing alone
recovered = ""
for pos in range(len(SECRET)):
best = max("0123456789abcdef",
key=lambda c: timed(recovered + c + "x"*(len(SECRET)-pos-1)))
recovered += best
print(f" position {pos:2}: {recovered}")
print("\nrecovered:", recovered, "| correct:", recovered == SECRET)
PY
You just extracted a secret without ever guessing it. That is why
hmac.compare_digest exists.
Common confusions
- "HMAC encrypts the message." It doesn't. The message travels in the clear; HMAC only proves it wasn't altered. For secrecy and integrity, use AEAD (Chapter 15).
- "I'll just hash the secret and message together." Length extension. Use HMAC.
- "Any hash works in HMAC." HMAC-MD5 is not immediately broken (HMAC's construction is resilient), but there's no reason to use it. Use HMAC-SHA256.
- "Timing attacks need physical access." They've been demonstrated successfully over networks. Don't rely on jitter to protect you.
Check yourself
- Why doesn't a plain hash protect against a deliberate attacker?
- What does HMAC add, and what does it require both parties to have?
- What's the difference between a MAC and a signature? What does a signature add?
- Why must tag comparison be constant-time?
- When would you use HMAC over a signature?
Further reading
- RFC 2104 — the HMAC specification. Short and readable; a good first RFC.
- Docs: Stripe's and GitHub's webhook-signature verification docs — real, well-designed examples you can copy the pattern from.
- Article: "A Lesson In Timing Attacks" — Nate Lawson. The classic
explanation of why
==is dangerous.
End of Part 3 — Milestone check
- I can name the four properties of a cryptographic hash
- I know MD5 and SHA-1 are broken, and specifically how
- I have seen two different files with the same MD5
- I can explain why SHA-256 is wrong for passwords
- I know what a salt is and that it isn't secret
- I can write correct password storage with Argon2id
- I have demonstrated a timing attack and understand
compare_digest