HackBlue

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.

9 min read · practice only with authorization

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.

Snippet
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)) # False

Lab: 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.

Snippet
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

  1. Sender

    Computes HMAC-SHA256 over timestamp and raw body with the shared secret.

  2. Transport

    Sends body plus signature and timestamp headers.

  3. Receiver

    Recomputes over the raw bytes; compare_digest.

  4. 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?