Tools · Cryptography and passwords in practice · 10 / 20
HMAC explained with Python: signing webhooks and API requests correctly
Use HMAC-SHA256 to prove a message came from someone holding a shared secret, verify webhook signatures in constant time, and include timestamps to stop replays.
What HMAC gives you
An HMAC is a keyed hash. Both sides share a secret key. The sender computes HMAC(key, message) and sends it along; the receiver recomputes it and compares. A match shows the message was not changed and was created by someone who knows the key. Plain sha256(key + message) is not a safe substitute because of length-extension properties of SHA-256; HMAC’s construction avoids that.
Webhook verification
Services such as GitHub and Stripe sign webhook payloads with HMAC-SHA256 and send the signature in a header. The receiver must compute the HMAC over the raw request body bytes, before any JSON parsing, and compare with hmac.compare_digest to avoid timing leaks. Including a timestamp in the signed data and rejecting old timestamps prevents replay.
import hmac, hashlib, time
SECRET = b'lab-webhook-secret'
def sign(body: bytes, ts: int) -> str:
msg = f'{ts}.'.encode() + body
return hmac.new(SECRET, msg, hashlib.sha256).hexdigest()
def verify(body: bytes, ts: int, received_sig: str, tolerance=300) -> bool:
if abs(time.time() - ts) > tolerance:
return False
expected = sign(body, ts)
return hmac.compare_digest(expected, received_sig)
body = b'{"event":"order.paid","id":42}'
ts = int(time.time())
sig = sign(body, ts)
print(verify(body, ts, sig)) # True
print(verify(body.replace(b'42', b'43'), ts, sig)) # FalseLab: verify a GitHub-style signature
GitHub sends X-Hub-Signature-256: sha256=<hex>. Write a small Flask or FastAPI endpoint in a lab that reads the raw body, computes the HMAC with your secret, and compares with the header using compare_digest. Send a test request with curl using a signature you compute with openssl, then change one character of the body and confirm the endpoint rejects it.
BODY='{"zen":"lab"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac 'lab-webhook-secret' | awk '{print $2}')
curl -s -X POST http://127.0.0.1:5000/hook -H "X-Hub-Signature-256: sha256=$SIG" -H 'Content-Type: application/json' --data "$BODY"Pitfalls
Comparing with == can leak timing information. Verifying after parsing and re-serializing JSON changes bytes and breaks signatures. Hard-coding the secret in source code exposes it; load it from a secret store. Rotate the secret if it may have leaked, supporting two secrets briefly during rotation.
Diagram
Webhook HMAC verification
- Sender
Computes HMAC-SHA256 over timestamp and raw body with the shared secret.
- Transport
Sends body plus signature and timestamp headers.
- Receiver
Recomputes over the raw bytes; compare_digest.
- Decide
Reject on mismatch or stale timestamp.
Check yourself
1. Which function should compare signatures in Python?
2. What must the receiver compute the HMAC over?
3. How do you prevent an attacker from replaying a captured valid webhook?