Frontend Labs
⋯

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

  1. Why doesn't a plain hash protect against a deliberate attacker?
  2. What does HMAC add, and what does it require both parties to have?
  3. What's the difference between a MAC and a signature? What does a signature add?
  4. Why must tag comparison be constant-time?
  5. 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

Generated from real-life-guide.md · 8 Oct 2026 16:36