L17 · Digital Signatures and MACs
Module 3: Cryptography Essentials · ~20 min · Lesson 17 of 71
🎯 What you'll learn
- Why
SHA-256(secret + message)is a broken MAC, and how HMAC fixes it - Why comparing two MACs with
==can leak the right answer, one byte at a time - Why a signed message (like a JWT) is still readable by anyone
- How to verify GitHub and Stripe webhooks correctly
🔥 Why this matters
Say your team exposes POST /webhooks/payments through an API gateway such as Kong, so a payment
partner can tell
payments-events when a premium has been paid. The URL isn't secret: it's in a config repo, a
runbook and the partner's dashboard. If the handler trusts any request, anyone can send
{"policy_id":"PL-100247","status":"PAID"}. The usual defence is an HMAC over the body, which is
easy to get almost right, and "almost right" means forgeable or replayable.
1. Picking the tool: MAC or signature?
L01's Q&A compared the two: a MAC (message authentication code) uses one shared secret, so anyone who can verify can also create; a digital signature uses a key pair, so only the private-key holder can create, and anyone with the public key can verify. Here's where each one already shows up in a typical API stack:
| You see | What it is | Key |
|---|---|---|
GitHub X-Hub-Signature-256 |
HMAC-SHA256 | Shared webhook secret |
Stripe Stripe-Signature |
HMAC-SHA256 | Shared whsec_... secret |
Kong hmac-auth plugin |
HMAC (SHA-256/384/512) | Shared per consumer |
AWS SigV4 (every aws CLI call) |
HMAC-SHA256 | Derived from your secret key |
JWT with alg: HS256 |
HMAC-SHA256 | Shared between issuer and API |
JWT with alg: RS256 / ES256 |
Signature | IdP private key, JWKS public key |
| TLS certificate, signed Git commit | Signature | Private key |
Most webhook "signatures" are really MACs; judge by the key model, not the name. Rule of thumb: if every verifier could safely also create, a MAC is simpler and faster. If many parties verify, or you need proof a third party will accept (non-repudiation, L01), use a signature.
2. Why hash(secret + message) is broken
The obvious homemade MAC is tag = SHA-256(secret || message). Only someone with the secret
can compute it, right? Not quite. SHA-256 (L14) processes input in 64-byte blocks, carrying an
internal state between them, and its output is the final state. An attacker can load a valid
tag as the starting state and keep hashing, never knowing the secret. This is a
length extension attack: given H(secret || msg) and the secret's length, the attacker
computes a valid tag for secret || msg || padding || extra.
LEGIT H( [secret][amount=100&to=bob][pad] ) ──▶ tag T
│
attacker loads T as the state, hashes [&to=eve] ◀─┘
FORGED H( [secret][amount=100&to=bob][pad][&to=eve] ) = tag T'
└──?───┘└ known (it was sent) ─┘└──new──┘
? = the secret, never seen. The server recomputes T' and accepts.
In 2009, Thai Duong and Juliano Rizzo showed Flickr's API signed requests as
MD5(secret || parameters), and forged calls by extending them. MD5, SHA-1 and SHA-256 are all
vulnerable. SHA-3 (whose "sponge" design never outputs its full state) and truncated variants
like SHA-512/256 are not, but the real fix is a construction designed as a MAC.
3. HMAC in one picture
HMAC (hash-based MAC, RFC 2104) runs the hash twice, with the key mixed in each time:
key ⊕ ipad ──┐
├─▶ SHA-256 ─▶ inner digest ──┐
message ─────┘ │
▼
key ⊕ opad ────────────────────────▶ SHA-256 ─▶ HMAC tag
ipad = 0x36 repeated, opad = 0x5c repeated (one block long)
The outer keyed hash means the tag you see is never a state an attacker can extend. Use a random key at least as long as the hash output (32 bytes for SHA-256); RFC 2104 "strongly discourages" shorter ones. And don't invent the encoding: if GitHub says "hex digest of the raw body", compute exactly that.
4. Comparing tags: constant time
The natural code to check a tag is if sent == expected. Most == implementations stop at
the first byte that differs, so a wrong first byte returns slightly faster than a wrong tenth.
expected: a 7 3 f 9 c ...
guess 1: 0 . . . . . ✗ stop after 1 byte (fastest)
guess 2: a 0 . . . . ✗ stop after 2 bytes (a bit slower)
guess 3: a 7 0 . . . ✗ stop after 3 bytes (slower again)
└─ timing says "first byte right", move to the next
Over a network that's nanoseconds hidden in jitter, but enough samples can reveal it; in 2009
Nate Lawson found Google's Keyczar library comparing HMACs this way. A constant-time
comparison checks every byte wherever the first mismatch is: hmac.compare_digest in Python,
MessageDigest.isEqual in Java, crypto.timingSafeEqual in Node. GitHub's docs say it
directly: "Never use a plain == operator."
5. Signing is not encrypting
Back to the L01 weak spot (integrity vs confidentiality): a MAC or signature proves integrity and authenticity, not confidentiality. A made-up HS256 JWT, split up:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ← header
.eyJzdWIiOiJDLTIwNDE3Iiwicm9sZSI6ImFnZW50IiwiZXhwIjox
NzkzOTg3MTg1fQ ← payload
.fCvq6wgx9GSV34wU-_GsbKLFCSj38GZl30PUHLgTGUc ← HMAC tag
Anyone can base64-decode the middle part, no key needed:
$ echo 'eyJzdWIiOiJDLTIwNDE3Iiwicm9sZSI6ImFnZW50Iiwi'\
'ZXhwIjoxNzkzOTg3MTg1fQ==' | base64 -d
{"sub":"C-20417","role":"agent","exp":1793987185}
So a tax ID in a signed-only token or webhook is not protected. If you need both properties, combine them in the right order:
- Encrypt-then-MAC: MAC the ciphertext and check it before decrypting, as AEAD modes like AES-GCM do (L01, L15). TLS 1.2's older MAC-then-encrypt CBC suites enabled padding-oracle attacks like Lucky 13 (2013).
- Sign-then-encrypt for signatures: sign, then encrypt the signed result, hiding the signature too. RFC 7519 recommends this order for nested JWTs.
🧪 Worked example: verifying a webhook
The two formats (from GitHub's and Stripe's docs) look like this:
X-Hub-Signature-256: sha256=<hex HMAC-SHA256(secret, raw body)>
Stripe-Signature: t=1793983585,v1=<hex HMAC-SHA256(
secret, "1793983585" + "." + raw body)>
GitHub signs only the body. Stripe also signs a timestamp, and sends one v1= per active
secret during a rotation.
Vulnerable (Flask, running in payments-events behind Kong):
@app.post("/webhooks/payments")
def payments_webhook():
event = request.get_json() # (1)
body = json.dumps(event).encode() # (1)
expected = hmac.new(SECRET, body,
hashlib.sha256).hexdigest()
sig = request.headers.get("X-Signature", "")
if sig != expected: # (2)
abort(401)
mark_paid(event["policy_id"]) # (3)
return "", 200
- Parsed, then re-serialized. The partner signed exact bytes;
json.dumpschanges spacing and number formats (1.10becomes1.1), so real requests fail, and someone "fixes" it by turning verification off. !=on strings leaks timing, as in section 4.- No timestamp. A request captured once (say, from a log) verifies forever: a replay.
Fixed (tested with Python 3 on made-up data):
import hashlib, hmac, json, time
TOLERANCE = 300 # seconds; Stripe's libraries default to 5 min
def verify_stripe_style(raw: bytes, header: str,
secret: bytes, now=None) -> bool:
try:
items = [p.split("=", 1) for p in header.split(",")]
ts = next(v for k, v in items if k == "t")
sigs = [v for k, v in items if k == "v1"]
age = (now or time.time()) - int(ts)
except (ValueError, StopIteration, AttributeError):
return False # fail closed (L04)
if abs(age) > TOLERANCE or not sigs:
return False # too old: replay
signed = ts.encode() + b"." + raw
mac = hmac.new(secret, signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(mac, s) for s in sigs)
def verify_github(raw: bytes, header: str,
secret: bytes) -> bool:
if not header or not header.startswith("sha256="):
return False
mac = hmac.new(secret, raw, hashlib.sha256).hexdigest()
return hmac.compare_digest("sha256=" + mac, header)
@app.post("/webhooks/payments")
def payments_webhook():
raw = request.get_data() # exact bytes
hdr = request.headers.get("Stripe-Signature", "")
if not verify_stripe_style(raw, hdr, SECRET):
abort(401)
event = json.loads(raw) # parse AFTER
...
verify_github returns True for GitHub's published test vector; a changed amount, a garbage
header, or an hour-old timestamp all return False. In Java (Spring), take @RequestBody byte[]
and compare with MessageDigest.isEqual. GitHub sends no timestamp, so dedupe on its
X-GitHub-Delivery ID (next section). And make sure nothing in front, like a Kong
request-transformer, rewrites the body.
🛠 Try it (optional, ~5 min)
All local. Output is from Homebrew OpenSSL 3.x+; LibreSSL differences are at the end.
1. Reproduce GitHub's test vector:
$ printf 'Hello, World!' | \
openssl dgst -sha256 -hmac "It's a Secret to Everybody"
SHA2-256(stdin)= 757107ea0eb2509fc211221cce984b8a37570b6d7586c22c
46f4379c8b043e17
It matches GitHub's docs. Use printf, not echo, which adds a newline.
2. Change one character of the body or the key: the HMAC changes completely. Compare
printf 'Hello, World!' | shasum -a 256, a plain hash anyone can compute without the secret.
3. Compute a Stripe-style signature with a fake secret:
SECRET='whsec_test_0000'; TS=1793983585
BODY='{"id":"evt_0001","type":"payment.succeeded","amount":125000}'
printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET"
Add 1 to TS and rerun: the signature changes, so an old request can't be made to look fresh.
4. Other syntaxes (same result; hexkey: takes a binary key):
printf '%s.%s' "$TS" "$BODY" | \
openssl dgst -sha256 -mac HMAC -macopt "key:$SECRET"
printf '%s.%s' "$TS" "$BODY" | \
openssl mac -digest SHA256 -macopt "key:$SECRET" HMAC
macOS notes: /usr/bin/openssl (LibreSSL 3.3.6) supports dgst -hmac and
-mac HMAC -macopt but prints bare hex for stdin, with no SHA2-256(stdin)= prefix. It lacks
openssl mac (OpenSSL 3.0+ only, uppercase output). A key typed on the command line lands in
shell history and ps, so use test secrets only.
🧠 Key takeaways
hash(secret || message)is forgeable by length extension; use HMAC (or a library's MAC).- Compare tags in constant time:
hmac.compare_digest,MessageDigest.isEqual. - Verify over the exact raw bytes received, then parse.
- A valid MAC doesn't mean a fresh request: sign a timestamp and reject old ones.
- Signing gives integrity and authenticity, never confidentiality: a signed JWT is readable.
📖 Words to know
| Term | Meaning |
|---|---|
| MAC | A short tag computed from a message and a shared secret; proves integrity and origin among key holders |
| HMAC | The standard MAC built from a hash function run twice with the key (RFC 2104) |
| Length extension attack | Using a hash's output as its internal state to append data and compute a valid H(secret‖msg‖extra) |
| Constant-time comparison | A comparison whose running time doesn't depend on where the first difference is |
| Timing attack | Learning a secret by measuring how long an operation takes |
| Replay attack | Re-sending a valid, captured request so it's processed again |
| Encrypt-then-MAC | Encrypting first, then MACing the ciphertext and checking it before decrypting |
⏭ Next time
L18: storing passwords, and why a fast hash like SHA-256 is exactly the wrong tool for it.
Replay attacks: why a valid signature isn't enough
The gap
A MAC answers one question: "did a key holder produce these exact bytes?" It doesn't answer "is this the first time I've seen them?" or "were they sent recently?" A request that was genuine once stays genuine forever, byte for byte. An attacker who gets a copy doesn't need the secret; they just send it again.
How do copies leak? Rarely through TLS itself. More often from places you control: a gateway
that logs full request headers and bodies, a debugging proxy, an APM tool that captures payloads,
a support ticket with a pasted curl command, or a webhook URL that was briefly pointed at a
test server.
PARTNER LOG / PROXY payments-events
│ POST /webhooks/payments │
│ Stripe-Signature: t=1793983585,v1=ccd9... │
│ {"policy_id":"PL-100247",...} │
│───────────────────┬────────────────────────▶│ ✔ processed once
│ │ copy saved │
│ ▼ │
│ [ attacker ] │
│ │ same bytes, days later │
│ └────────────────────────▶│ ✔ processed again?
Three defences, used together
1. Freshness: a signed timestamp. Stripe puts t= inside the signed string, so it can't be
changed without breaking the MAC, and rejects anything older than a tolerance (5 minutes by
default in Stripe's libraries; Kong's hmac-auth plugin uses a 300-second clock_skew; AWS
rejects most requests more than 5 minutes from their timestamp). An unsigned X-Sent-At header
is useless: the attacker just edits it. Stripe's docs also warn that a tolerance of 0 turns the
check off entirely in their libraries, and that your servers need NTP, or real requests fail.
2. Uniqueness: remember what you've processed. A timestamp still leaves a 5-minute window,
and GitHub sends no timestamp at all. So record each event's ID and refuse duplicates. GitHub
gives you X-GitHub-Delivery, a GUID per event; Stripe gives you the event id (evt_...).
Dedupe on the event ID, not on the signature: Stripe's retries carry a new timestamp and a new
signature for the same event, and GitHub's redeliveries keep the same delivery GUID.
3. Idempotent handlers. Even with both checks, design the effect so running it twice is
harmless: "set status of PL-100247 to PAID for invoice INV-5521", not "add $1,250 to the balance".
This is the same idempotency idea as PUT in L09.
🧪 Worked example: dedupe with MongoDB
A unique index makes the database enforce "process once", even with several pods racing:
db.webhook_events.createIndex({ eventId: 1 }, { unique: true })
// keep IDs for 7 days, longer than the sender's retry window
db.webhook_events.createIndex(
{ receivedAt: 1 }, { expireAfterSeconds: 604800 })
from datetime import datetime, UTC
from pymongo.errors import DuplicateKeyError
def handle(event_id: str, raw: bytes) -> int:
try:
events.insert_one({"eventId": event_id,
"receivedAt": datetime.now(UTC)})
except DuplicateKeyError:
return 200 # seen it: ack, do nothing
enqueue_for_processing(raw)
return 200
Two details matter. Return 200 for a duplicate, or the sender will keep retrying. And choose
the TTL from the sender's retry window, not the timestamp tolerance: Stripe retries for up to
three days, so IDs must live at least that long. For GitHub, with no timestamp, the dedupe store
is your only replay defence, so keep IDs for as long as an old delivery would still do harm.
🧠 Extra takeaways
- A valid MAC proves origin and integrity, not freshness or uniqueness.
- Put the timestamp inside the signed data, and keep the tolerance small but nonzero.
- Dedupe on the event ID with a unique index; return
200for duplicates. - Make the handler idempotent, so a replay that slips through does no harm.
| Term | Meaning |
|---|---|
| Freshness | Evidence that a message was created recently |
| Nonce | A value used only once, so a repeat can be detected |
| Clock skew | How far apart two machines' clocks are; tolerances must allow for it |
| Idempotent handler | Processing the same event twice gives the same result as once |
Signing requests: how AWS SigV4 proves who sent an API call
Every AWS call is a MAC
When boto3 in an Airflow task or aws s3 ls on your laptop calls AWS, it never sends your
secret access key. If you sign in through IAM Identity Center, you get temporary credentials (access key ID, secret
key, session token). The SDK uses the secret to compute an HMAC over the request, and AWS, which
also knows the secret, recomputes it. This is Signature Version 4 (SigV4).
The four steps
1. CANONICAL REQUEST 2. STRING TO SIGN
GET AWS4-HMAC-SHA256
/ 20261106T164625Z
Action=ListUsers&Version=... 20261106/us-east-1/iam/
host:iam.amazonaws.com aws4_request
x-amz-date:20261106T164625Z hex(SHA-256(canonical req))
host;x-amz-date │
hex(SHA-256(body)) │
▼
3. SIGNING KEY (chain of HMACs) 4. SIGNATURE
k1 = HMAC("AWS4"+secret, HMAC(kSigning,
"20261106") string to sign)
k2 = HMAC(k1, "us-east-1") ─▶ hex, into the
k3 = HMAC(k2, "iam") Authorization header
kSigning = HMAC(k3, "aws4_request")
The result goes in a header like this (made-up key ID; one line in practice):
Authorization: AWS4-HMAC-SHA256
Credential=AKIAIOSFODNN7EXAMPLE/20261106/us-east-1/iam/aws4_request,
SignedHeaders=host;x-amz-date,
Signature=<64 hex chars>
What each piece protects
- Canonical request: method, path, query and the listed headers are normalized, so both sides hash identical bytes (the same raw-bytes problem as webhooks, solved by defining one exact format). Change any signed part and the signature fails: that's integrity.
- Payload hash: the body is covered too, via its SHA-256 (S3 can use
UNSIGNED-PAYLOADinstead, which leaves the body unprotected by the signature). x-amz-date: AWS rejects most requests more than 5 minutes from this time, which limits replay. Within that window a captured request could still be resent, so TLS still matters.- Scoped signing key: the key chain bakes in date, region and service. A leaked
kSigningworks only for that day, region and service, unlike the secret access key itself. x-amz-security-token: with temporary credentials, the session token travels with the request (some services require it in the signed headers, others only alongside), and AWS checks it hasn't expired.
SigV4a is the asymmetric variant for multi-region requests (for example S3 Multi-Region
Access Points): it derives an ECDSA P-256 key pair from your secret, so it's a real digital
signature, and AWS only needs to store the public key. Presigned S3 URLs are SigV4 in the
query string: anyone holding the URL can use it until X-Amz-Expires runs out, so treat one
like a password.
🛠 Try it: derive a SigV4 signing key locally (~3 min)
This uses AWS's public documentation example secret, so no real credentials are involved:
hm() { openssl dgst -sha256 -mac HMAC -macopt "$1" \
| awk '{print $NF}'; }
K='wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY'
k1=$(printf 20120215 | hm "key:AWS4$K")
k2=$(printf us-east-1 | hm "hexkey:$k1")
k3=$(printf iam | hm "hexkey:$k2")
printf aws4_request | hm "hexkey:$k3"
# f4780e2d9f65fa895f9c67b32ce1baf0b0d8a43505a000a1a9e090d414db404d
Homebrew OpenSSL, /usr/bin/openssl (LibreSSL) and Python's hmac module all gave this
same value when the lesson was written. Change the date to 20120216 and watch the whole key change: that's the
scoping at work. To see a real canonical request from your own account, aws sts get-caller-identity --debug prints it (read-only, but it does call AWS with your credentials).
🧠 Extra takeaways
- SigV4 is HMAC over a canonical form of the request; the secret key never leaves your machine.
- Date, region and service are baked into the signing key, so leaks are scoped.
- The 5-minute timestamp window limits replay but doesn't eliminate it.
- SigV4a and presigned URLs are variations: a real ECDSA signature, and a bearer link.
| Term | Meaning |
|---|---|
| SigV4 | AWS's request-signing scheme: HMAC-SHA256 over a canonical request |
| Canonical request | A strictly normalized form of an HTTP request so both sides hash the same bytes |
| Credential scope | The date/region/service/aws4_request string that limits where a signature is valid |
| SigV4a | The asymmetric (ECDSA P-256) variant used for multi-region requests |
| Presigned URL | A URL with a SigV4 signature in its query string; works for anyone until it expires |
The quiz for this lesson and the Tests section need JavaScript.