The previous lesson closed with a trick question: if the SHA-256 fingerprint travels alongside the message, what stops an attacker from replacing both? Nothing — and that's exactly the difference between integrity (the bytes weren't corrupted) and authenticity (the bytes come from whoever claims to have sent them). This lesson introduces the tool that gives you both at once when sender and receiver share a key: the MAC (Message Authentication Code), and its standard incarnation, HMAC. Along the way you'll understand why the intuitive shortcut hash(key + message) is broken (the length extension attack), and we'll settle two concrete MediNube debts: signing the webhooks the portal sends to Clínica Sol and Luna Medical Center, and no longer storing the password recovery token we generated in 01-03 in the clear. This is the piece that turns the previous lesson's hash into a guarantee against enemies, not just against disks that corrupt bits.
Contents
- The demonstration: a plain hash doesn't authenticate
- What a MAC is and what it guarantees (and doesn't)
- Why
hash(key + message)is broken: length extension - HMAC: the correct construction
- HMAC in Python: the
hmacmodule - Central case: signing MediNube's webhooks
- Debt settled: hashing the recovery token
- The MAC you were already using without knowing it: AES-GCM's tag
- Hash vs MAC vs digital signature
The demonstration: a plain hash doesn't authenticate
MediNube notifies clinics over HTTP when there's news ("new appointment created," "report available"). First legacy attempt, found in the portal's codebase:
# legacy code - webhook notifier
import hashlib, json
def send_webhook_BAD(event: dict) -> dict:
body = json.dumps(event).encode("utf-8")
fingerprint = hashlib.sha256(body).hexdigest()
return {"body": body, "integrity_header": fingerprint}The receiver (Clínica Sol) recomputes the SHA-256 of the body and compares it against the header. Sounds reasonable... until you look at it as an attacker. Suppose you control a proxy between MediNube and the clinic:
import hashlib, json
# The intercepted legitimate message:
package = send_webhook_BAD({"event": "appointment_created", "patient": "ana.perez"})
# The attacker crafts ANOTHER message and recomputes the hash themselves:
forged = json.dumps({"event": "appointment_cancelled", "patient": "ana.perez"}).encode()
package["body"] = forged
package["integrity_header"] = hashlib.sha256(forged).hexdigest()
# The receiver's verification... passes without a hitch:
ok = hashlib.sha256(package["body"]).hexdigest() == package["integrity_header"]
print(ok) # True - the clinic accepts the forged messageThe check is mathematically correct and completely useless: SHA-256 is a public function (Kerckhoffs, golden rule 2) that requires nothing the attacker doesn't already have. The hash only ties the header to the body; it ties nothing to MediNube. For that, the calculation needs to involve something the attacker doesn't know: a shared secret key.
What a MAC is and what it guarantees (and doesn't)
A MAC is a function that takes a secret key and a message and produces a short tag:
The receiver, who shares the key, recomputes the tag and compares it. The security property is called existential unforgeability: an attacker who sees as many (message, tag) pairs as they like can't produce a valid tag for any new message without the key. With that, a correct tag proves two things at once:
- Integrity: the message hasn't changed by a single bit since the tag was computed (the underlying hash's avalanche effect is still working for us).
- Authenticity: it was generated by someone who knows the key. If only MediNube and Clínica Sol know it, and the clinic didn't send it to itself, it came from MediNube.
Just as important is what a MAC does not give you:
- No confidentiality. The message travels as-is (in the clear, in our webhook); the MAC only authenticates it. If it also needs to be hidden, you encrypt it (with AEAD, which as we'll see already includes its own MAC).
- No non-repudiation. The key is shared: any message MediNube could authenticate, Clínica Sol could also have forged (and vice versa). Before a judge, "it has a valid MAC" doesn't prove which of the two parties generated it. When you need only one party to be able to generate the proof and anyone to be able to verify it — the electronic prescriptions we still owe — you'll need asymmetric-key digital signatures: lesson 04-03.
Why hash(key + message) is broken: length extension
With the idea of "mixing in a key," the following legacy code almost looks like the solution:
def tag_BAD(key: bytes, message: bytes) -> str:
return hashlib.sha256(key + message).hexdigest() # NO: length extensionIt's broken, and the reason teaches you a lot about how hashes work under the hood. Recall the Merkle–Damgård "grinder" from 03-01: SHA-256 processes the message in blocks, updating an internal state, and the final hash is, literally, the internal state after the last block. There's no final step that disguises it.
Consequence: if an attacker knows sha256(key + message), they know the grinder's internal state at that point. They can load that state and keep grinding: compute sha256(key + message + padding + extra) for an extra of their choosing, without knowing the key (they only need its length, which can be guessed by trial). This is the length extension attack:
flowchart LR
A[initial state] -->|key + message| B[state S]
B -->|"= published tag"| C[attacker READS S from the tag]
C -->|"+ padding + extra"| D[valid tag for the extended message]
Translated to MediNube: from the legitimate webhook {"event": "appointment_created"...} and its tag_BAD, the attacker derives a valid tag for that same body with extra content appended — without the key. In real APIs (signed URL parameters, for instance) this attack has been exploited for years; the famous case was the Flickr API in 2009.
Notes to round out the picture:
- It affects "pure" Merkle–Damgård hashes: MD5, SHA-1, SHA-256, SHA-512. It does not affect SHA-3 (the sponge doesn't expose its full state), nor BLAKE2, nor SHA-384 (truncated output: bits of the state are missing).
hash(message + key)(key at the end) avoids this attack but inherits other flaws (a collision in the hash turns into a MAC forgery). Homemade fixes always end up this way — golden rule 1: don't invent your own cryptography, not even your own "combinations" of good pieces.
HMAC: the correct construction
HMAC (1996, RFC 2104) is the standardized, proven way to build a MAC out of any hash function. Its scheme:
You don't need to memorize it, but you should understand the trick:
- The key is mixed in twice, with two different constants (
ipad, inner padding;opad, outer padding), and there are two nested hash passes. - The outer pass is what kills length extension: the internal state of the hash that processed the message is no longer the tag — the tag is the hash of that result combined with the key again. The attacker can't "keep grinding" because the intermediate result is never published.
- HMAC has a security proof: if the underlying hash meets reasonable properties, HMAC is a secure MAC. The design is so robust that HMAC-MD5 remains without practical attacks despite MD5 being broken on collisions — not an excuse to use it, but it gives a sense of the margin.
HMAC-SHA256produces 32-byte tags and is the de facto standard in APIs. You've already used it without looking: it's the piece PBKDF2 iterates thousands of times and HKDF's internal engine (02-04).
HMAC in Python: the hmac module
The standard library ships everything you need:
import hmac, hashlib
key = bytes.fromhex(
"8f4e2a11c96d3b70e5a8d4c2f01b9e6633d7a0558c1f4b2e9d60a3517c8e4f0b"
) # 32 bytes from the CSPRNG, shared with the clinic (fictional, of course)
message = b'{"event": "appointment_created", "patient": "ana.perez"}'
tag = hmac.new(key, message, hashlib.sha256).hexdigest()
print(tag) # e.g. d7539f05...Breakdown:
hmac.new(key, message, digestmod)creates and feeds the HMAC. The third argument (the hash function) is required; usehashlib.sha256. Likehashlibobjects, it supports.update()for chunked messages and.digest()/.hexdigest()for the tag.- The key must come from the CSPRNG (golden rule 3): 32 bytes from
secrets.token_bytes(32)when it's issued. Never a password, never anything "memorable." - To verify, never
==:
def verify(key: bytes, message: bytes, received_tag: str) -> bool:
expected = hmac.new(key, message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received_tag)Here we really are comparing a secret: the correct tag is something the attacker doesn't know and wants to guess. A plain == gives up at the first differing byte, and that timing difference, measured patiently, lets you guess the tag byte by byte — the timing attack from 01-04. hmac.compare_digest (equivalent to secrets.compare_digest) takes the same time regardless of how much matches: golden rule 6 applied where it belongs. Contrast this with 03-01's public fingerprint, where == was legitimate — the criterion is always "is what I'm comparing a secret?"
Central case: signing MediNube's webhooks
Let's actually fix the notifier. The design follows the pattern used by Stripe, GitHub, or Slack for their webhooks, adapted to our house:
- Each receiving clinic has its own webhook key (32 bytes from
secrets.token_bytes(32)), handed over when the integration is configured. One key per clinic: if Luna Medical Center's leaks, Clínica Sol's stays safe, and you can rotate one without touching the other (crypto-agility, rule 8). - MediNube sends two headers:
X-MediNube-Timestamp(Unix seconds of the send) andX-MediNube-Firma(the HMAC tag, prefixed with the signature scheme version:v1=<hex>). - The HMAC covers not just the body, but
timestamp || "." || body. Separating with a.and including the timestamp defends against replay: without it, an attacker who captures a legitimate webhook ("report available") could resend it a thousand times, days later, with a perfectly valid signature. By signing the timestamp, the receiver can reject old messages.
# medinube/webhooks.py - sender side (MediNube)
import hmac, hashlib, time
def sign_webhook(clinic_key: bytes, body: bytes) -> dict:
"""Generates the authentication headers for an outgoing webhook."""
timestamp = str(int(time.time()))
base = timestamp.encode("utf-8") + b"." + body
tag = hmac.new(clinic_key, base, hashlib.sha256).hexdigest()
return {
"X-MediNube-Timestamp": timestamp,
"X-MediNube-Firma": f"v1={tag}",
}And the verification, which MediNube documents for the clinics' developers:
# receiver side (e.g. Clinica Sol's system)
import hmac, hashlib, time
TOLERANCE = 300 # seconds: 5-minute clock margin
def verify_webhook(key: bytes, body: bytes,
timestamp: str, signature: str) -> bool:
# 1. Freshness: reject old messages (anti-replay)
if abs(time.time() - int(timestamp)) > TOLERANCE:
return False
# 2. Scheme version
if not signature.startswith("v1="):
return False
# 3. Recompute and compare in constant time
base = timestamp.encode("utf-8") + b"." + body
expected = hmac.new(key, base, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature[3:])Details that make the difference between this code and a vulnerable one:
- The verified timestamp is the signed one. The attacker can't "refresh" a captured message by changing the timestamp header, because the tag would stop matching. Replay is blocked beyond the 5-minute window (a window that exists purely to tolerate clock drift). If your application can't tolerate any replay, even inside the window, add a unique event ID and deduplicate — an application-layer concern, not a cryptographic one (rule 9).
- Verification happens on the raw body (the bytes received), not on re-serialized JSON: two serializers can order keys or space things differently, and the signature wouldn't match even for a legitimate message. Sign bytes, verify bytes.
- The
v1=prefix is the same move as the version byte in 02-03's medical record format: if we migrate the signature scheme tomorrow, both sides can coexist withv1andv2during the transition. - Context note: webhooks also travel over HTTPS (Module 5), which already encrypts and authenticates the channel. The HMAC signature authenticates the message end to end: it survives proxies, queues, retries, and intermediate logs, and lets the clinic validate the webhook even if TLS terminates at a third-party load balancer.
Course debt settled: MediNube's outgoing webhooks are now signed.
Debt settled: hashing the recovery token
Second debt, this one from 01-03. There, we generated password recovery tokens with secrets.token_urlsafe(32) and stored them as-is in the database — with a promise to fix it later. The problem: if an attacker reads the DB (SQL injection, a leaked backup), they get valid, usable tokens to reset any user's password, including ana.perez's.
The fix is the same idea we'll apply to passwords in 03-03, but with a crucial difference worth understanding now: store the token's hash, not the token.
# medinube/recovery.py
import hashlib, secrets
def create_recovery_token(username: str) -> str:
token = secrets.token_urlsafe(32) # 32 bytes of entropy = 256 bits
index = hashlib.sha256(token.encode("utf-8")).hexdigest()
save_to_db(username, index) # the hash is stored, not the token
return token # the token only travels in the email
def redeem_token(submitted_token: str):
index = hashlib.sha256(submitted_token.encode("utf-8")).hexdigest()
return find_in_db(index) # exact lookup by the hash- The real token only ever exists in the user's email and in memory during the request. The DB stores
sha256(token): if it leaks, the attacker has fingerprints they can't recover the tokens from (preimage resistance over a 256-bit-entropy input — here it's genuinely unpredictable). - Why is a fast SHA-256 enough, if in 03-01 we said "fast hash" and "secret" don't mix? Because of the input's entropy. A human password gets attacked by dictionary: the candidate space is small and a fast hash flies through it. This token has 256 bits of CSPRNG entropy: no dictionary is possible, brute force is 2^256. Argon2's slowness is a patch for low human entropy; with full entropy, it's overkill. This contrast is exactly the doorway into 03-03.
- Design bonus: since the hash is deterministic, it doubles as a search index in the DB (exact lookup by
index). An equally valid variant is storingHMAC(server_key, token), which additionally requires compromising the server's key just to test candidates; for a 256-bit token, plain SHA-256 is already sufficient and simpler. - The comparison happens implicitly when looking up the index in the DB; if your implementation compares the hash "by hand," it's a derived secret again: use
compare_digest. And remember to give the token a short expiry and single use — application layer, once more.
The MAC you were already using without knowing it: AES-GCM's tag
If HMAC sounds conceptually familiar, it's because Module 2 already had you using a MAC every day: AES-GCM's authentication tag (02-03) is exactly this — a MAC (GMAC, based on polynomial arithmetic instead of hashes) computed over the ciphertext and the AAD, which is what triggers our TamperedMedicalRecord exception whenever someone tampers with an encrypted medical record. And Poly1305, half of ChaCha20-Poly1305, is another MAC from that "single-use-per-derived-key" family, designed to be blazingly fast. The hierarchy shakes out like this:
- Encrypting and authenticating at once? → AEAD (the MAC is included; don't bolt an HMAC on top).
- Authenticating without encrypting (the receiver needs to read the message, like our webhooks)? → HMAC.
Hash vs MAC vs digital signature
The table that orders this module and previews the next one:
| Hash (03-01) | MAC/HMAC (this lesson) | Digital signature (04-03) | |
|---|---|---|---|
| Needs a key | No | Yes, shared (symmetric) | Yes, a public/private pair |
| Integrity | Yes | Yes | Yes |
| Authenticity | No | Yes (among those sharing the key) | Yes (to anyone) |
| Non-repudiation | No | No (either party can generate the tag) | Yes (only the private key's owner signs) |
| Who can verify | Anyone | Only whoever has the key | Anyone, with the public key |
| Cost | Very low | Very low | High (a thousand times slower) |
| In MediNube | Export fingerprints | Webhooks, token index | Electronic prescriptions (pending) |
Common Mistakes and Tips
- Homemade
hash(key + message). Length extension on SHA-256.hmac.newexists; use it. If you ever seesha256(secret + data)in legacy code, that's an audit finding, not a style quirk. - Verifying the tag with
==. A timing attack on a secret.hmac.compare_digest, always, on both sides of the integration (golden rule 6). - Signing the re-serialized JSON instead of the received bytes. The signature must be computed and verified over the raw bytes of the body; re-serializing introduces invisible differences (key order, whitespace, unicode) that break legitimate signatures — and rushed "fixes" for that tend to end up weakening the verification.
- Forgetting the timestamp (or not including it in what's signed). Without it, any captured webhook is replayable forever. And if the timestamp travels but isn't inside the HMAC, the attacker just updates it and the replay comes back to life.
- Reusing the same webhook key for every clinic, or deriving the key from something guessable. One key per receiver, from the CSPRNG, rotatable. If you need several keys from a master secret, you already know the tool: HKDF with a different
info(02-04). - Adding HMAC on top of AES-GCM "for extra security." GCM's tag is already a MAC over the ciphertext and AAD; duplicating it adds complexity and attack surface, not security.
- Tip: when deciding what bytes to sign, ask yourself what an attacker could change without invalidating the tag. Anything that must stay immutable (timestamp, version, recipient) has to be inside what's signed — the same logic as 02-03's AAD.
Exercises
Exercise 1. This legacy endpoint verifies incoming webhooks in Luna Medical Center's system. Find the three flaws and explain the attack each one enables:
def verify_BAD(key, body, headers):
tag = hashlib.sha256(key + body).hexdigest()
return tag == headers["X-MediNube-Firma"]Exercise 2. MediNube wants to add the destination clinic to each webhook, so that a webhook signed for Clínica Sol can't be presented to Luna Medical Center even if both somehow shared a key due to a configuration mistake. Modify sign_webhook/verify_webhook to include the clinic identifier in the signed material, without adding new headers the receiver has to read (hint: the receiver already knows who it is).
Exercise 3. A colleague proposes storing recovery tokens with Argon2 "because it's the most secure option for secrets, better than SHA-256." Explain (a) why it adds no real security here, and (b) what operational cost it introduces that SHA-256 doesn't have (hint: think about how the token is looked up in the DB, knowing Argon2 uses a random salt).
Solutions
Solution 1. (1) sha256(key + body) is the construction vulnerable to length extension: an attacker can extend a legitimate webhook with extra content and compute a valid tag without the key — it should be hmac.new(key, body, hashlib.sha256). (2) tag == ... compares a secret with ==: a timing attack that lets you reconstruct the valid tag byte by byte — it should be hmac.compare_digest. (3) There's no timestamp or freshness check: any captured webhook can be replayed indefinitely — you need to sign timestamp || "." || body and reject messages outside the tolerance window.
Solution 2. The identifier is included in the signed base; each receiver verifies with its own identifier, which it already knows, without any new header:
def sign_webhook(key: bytes, body: bytes, clinic: str) -> dict:
timestamp = str(int(time.time()))
base = f"{timestamp}.{clinic}.".encode("utf-8") + body
tag = hmac.new(key, base, hashlib.sha256).hexdigest()
return {"X-MediNube-Timestamp": timestamp, "X-MediNube-Firma": f"v1={tag}"}
def verify_webhook(key: bytes, body: bytes, timestamp: str,
signature: str, my_id: str = "clinica-sol") -> bool:
if abs(time.time() - int(timestamp)) > 300 or not signature.startswith("v1="):
return False
base = f"{timestamp}.{my_id}.".encode("utf-8") + body
expected = hmac.new(key, base, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature[3:])If the webhook was meant for a different clinic, the reconstructed base differs and the tag won't match. It's the same principle as 02-03's patient=... AAD: bind the context to the authenticated material. (In a real change you'd also need to bump the scheme to v2=, since v1 tags would no longer be compatible.)
Solution 3. (a) Argon2 is deliberately slow to compensate for the low entropy of human passwords (a small candidate space → each attempt needs to be made expensive). The token has 256 bits of CSPRNG entropy: the candidate space is 2^256 and no attacker can enumerate it, at any speed. SHA-256 already leaves brute force at "impossible"; Argon2 would leave it at "impossible while also burning your RAM." (b) Argon2 generates a random salt per hash: the same token produces a different hash each time, so you can't locate the record by hashing the submitted token — you'd have to run verify (slow, memory-heavy) against every candidate row in the table. SHA-256, being deterministic, works as an exact, instant search index. Match the tool to the problem: golden rule 7.
Conclusion
The gap left by 03-01 is closed: when a fingerprint and a message travel together, the answer is to mix in a shared secret key — a MAC — and the correct way to do it with hashes is HMAC, whose two nested passes neutralize the length extension that breaks the hash(key + message) shortcut. At MediNube that translated into two settled debts: outgoing webhooks now carry X-MediNube-Firma: v1=<hmac> over timestamp.body (anti-replay included, verification with compare_digest), and the DB no longer stores recovery tokens but their SHA-256 — a fast hash that's genuinely sufficient here, because the token has 256 bits of entropy that no dictionary can reach.
That last sentence is the springboard for the next lesson: what about when the secret doesn't have entropy to spare? ana.perez's password, and everyone else's on the portal, are human words — predictable, reused — and storing them well is web development's most common (and most commonly mishandled) security problem. We've brushed against the answer twice: the slow KDFs from 02-04 and 03-01's "a fast hash won't do." In 03-03: Secure Password Storage we bring the pieces together: from the historical disaster (plaintext, unsalted MD5, rainbow tables) to Argon2id with argon2-cffi, with MediNube's full registration and login flow and the migration of old hashes. It's the last debt left in this module. Let's go settle it.
Applied Cryptography Course
Module 1: Cryptography Fundamentals
- What Cryptography Is and What It's For
- Encoding, Obfuscation, and Encryption
- Randomness and Entropy
- Kerckhoffs's Principle and the Golden Rules
Module 2: Symmetric Cryptography
- Symmetric Encryption: AES and ChaCha20
- Modes of Operation
- Authenticated Encryption (AEAD)
- Key Derivation (KDF)
Module 3: Hashes, MACs, and Passwords
Module 4: Asymmetric Cryptography
- Public-Key Fundamentals and RSA
- Elliptic Curve Cryptography
- Digital Signatures
- Key Exchange: Diffie-Hellman
- Hybrid Encryption
