Anyone on the internet can POST to your webhook URL. Verifying signatures is how you know the request came from the legitimate sender, not an attacker.
The pattern: the sender computes HMAC-SHA256(payload_bytes, shared_secret) and includes the hex digest as a header. You recompute the same HMAC on receipt and compare. If they match, the request is authentic and untampered.
import hmac
import hashlib
payload = b'{"event":"ping"}'
secret = b"shh-its-a-secret"
digest = hmac.new(secret, payload, hashlib.sha256).hexdigest()
print(digest[:16] + "...")What property does HMAC give us that a plain hash doesn't?
A plain SHA-256 of the payload proves nothing — anyone can compute it. HMAC mixes in the secret, so only parties holding the same secret can produce the same digest. The sender and you. An attacker without the secret can't forge a valid signature, even if they intercept and modify the payload.
And how do we compare?
With hmac.compare_digest(a, b). Why not ==? Because == short-circuits — it returns False at the first mismatched character. An attacker measuring response timing across millions of requests could narrow in on the correct signature one byte at a time. hmac.compare_digest runs in constant time regardless of how many leading bytes match. It's a one-line defense against a real attack class.
HMAC = Hash-based Message Authentication Code. Given a shared secret and a payload, both sides can compute the same digest. Anyone without the secret can't forge or modify the payload undetected.
Properties:
import hmac
import hashlib
digest = hmac.new(
key=secret_bytes, # bytes — your shared secret
msg=payload_bytes, # bytes — the raw HTTP body
digestmod=hashlib.sha256, # algorithm
).hexdigest() # hex string, 64 chars# wrong — vulnerable to timing attacks
if computed == provided:
...
# right — constant-time comparison
if hmac.compare_digest(computed, provided):
...Use hmac.compare_digest for every signature, token, or secret comparison. The cost is one extra import; the benefit is eliminating an entire attack class.
HMAC works on bytes. If your secret or payload is a string, encode it: secret.encode("utf-8"). If you forget, Python 3 raises TypeError immediately — fail-fast, no silent corruption.
How providers send the signature:
Stripe-Signature header, format t=<ts>,v1=<digest> (timestamp + digest)X-Hub-Signature-256: sha256=<digest>Linear-Signature: <digest> plus a request-timestamp headerThe shape varies; the verification step doesn't.
Never hardcode the secret. Store it in environment variables (week 2 covers this in detail). The provider gives you the secret once — paste into your env, never check it into git.
hmac and hashlib are standard library — Pyodide has them out of the box. Today's exercise runs entirely in the browser, no sandbox. The same code drops verbatim into a real webhook handler.
Anyone on the internet can POST to your webhook URL. Verifying signatures is how you know the request came from the legitimate sender, not an attacker.
The pattern: the sender computes HMAC-SHA256(payload_bytes, shared_secret) and includes the hex digest as a header. You recompute the same HMAC on receipt and compare. If they match, the request is authentic and untampered.
import hmac
import hashlib
payload = b'{"event":"ping"}'
secret = b"shh-its-a-secret"
digest = hmac.new(secret, payload, hashlib.sha256).hexdigest()
print(digest[:16] + "...")What property does HMAC give us that a plain hash doesn't?
A plain SHA-256 of the payload proves nothing — anyone can compute it. HMAC mixes in the secret, so only parties holding the same secret can produce the same digest. The sender and you. An attacker without the secret can't forge a valid signature, even if they intercept and modify the payload.
And how do we compare?
With hmac.compare_digest(a, b). Why not ==? Because == short-circuits — it returns False at the first mismatched character. An attacker measuring response timing across millions of requests could narrow in on the correct signature one byte at a time. hmac.compare_digest runs in constant time regardless of how many leading bytes match. It's a one-line defense against a real attack class.
HMAC = Hash-based Message Authentication Code. Given a shared secret and a payload, both sides can compute the same digest. Anyone without the secret can't forge or modify the payload undetected.
Properties:
import hmac
import hashlib
digest = hmac.new(
key=secret_bytes, # bytes — your shared secret
msg=payload_bytes, # bytes — the raw HTTP body
digestmod=hashlib.sha256, # algorithm
).hexdigest() # hex string, 64 chars# wrong — vulnerable to timing attacks
if computed == provided:
...
# right — constant-time comparison
if hmac.compare_digest(computed, provided):
...Use hmac.compare_digest for every signature, token, or secret comparison. The cost is one extra import; the benefit is eliminating an entire attack class.
HMAC works on bytes. If your secret or payload is a string, encode it: secret.encode("utf-8"). If you forget, Python 3 raises TypeError immediately — fail-fast, no silent corruption.
How providers send the signature:
Stripe-Signature header, format t=<ts>,v1=<digest> (timestamp + digest)X-Hub-Signature-256: sha256=<digest>Linear-Signature: <digest> plus a request-timestamp headerThe shape varies; the verification step doesn't.
Never hardcode the secret. Store it in environment variables (week 2 covers this in detail). The provider gives you the secret once — paste into your env, never check it into git.
hmac and hashlib are standard library — Pyodide has them out of the box. Today's exercise runs entirely in the browser, no sandbox. The same code drops verbatim into a real webhook handler.
Create a free account to get started. Paid plans unlock all tracks.