Signing requests and webhooks with HMAC

Backend security Mid-level Python 3.13+ PostgreSQL 17+ Reviewed By Best Latest Apps

Predict the output

The receiver verifies once over the raw bytes and once after parsing and re-encoding the JSON. What prints?

Python 3.13
import hashlib
import hmac
import json

SECRET = b"whsec_demo"


def signature(ts: int, body: bytes) -> str:
    msg = str(ts).encode() + b"." + body
    return hmac.new(SECRET, msg, hashlib.sha256).hexdigest()


ts = 1767225600
raw = b'{"event":"paid","id":"evt_1"}'
sig = signature(ts, raw)  # what the sender put in the header

reencoded = json.dumps(json.loads(raw)).encode()
print(hmac.compare_digest(signature(ts, raw), sig))
print(hmac.compare_digest(signature(ts, reencoded), sig))
print(reencoded.decode())

Options
Hint 1 of 1

Does json.dumps write the same bytes the sender sent?

Choose an answer, then open Show answer below to check it.

Show answer

Answer: A True, False, then the body with spaces after : and ,

Correct. json.dumps adds spaces by default, so the re-encoded bytes differ from what was signed.

An HMAC covers exact bytes. Parsing and re-encoding JSON can change spacing, key order or escapes, so verify over the raw request body before you parse it.

Why not the others?
  • B True, True, then the body unchanged. Parsing and re-encoding doesn't give back the same bytes; json.dumps uses its own separators.
  • C False, False, then the body unchanged. The first check uses the exact bytes that were signed, so it passes.
  • D True, True, then the body with spaces. Any byte change, even a space, gives a different HMAC.

30-second answer

Share a secret with the receiver and send an HMAC-SHA256 of a timestamp plus the exact raw body in a header. The receiver recomputes it over the raw bytes, compares in constant time, and rejects timestamps older than a few minutes so captured requests can't be replayed. Storing processed event ids gives replay protection inside the window too. For signatures third parties verify without a shared secret, use asymmetric signatures (Ed25519).

Practise saying it · 30 s. No recording, just a timer

Sign a webhook.

Say your answer out loud, then check which of these points you covered:

  • HMAC-SHA256 of ts + raw body
  • Constant-time compare
  • Timestamp window
  • Event ids
  • Rotate with two secrets

Deep dive

Verify over the raw body before JSON parsing; re-serialised JSON won't match.

Support two secrets during rotation.

hmac.compare_digest avoids timing leaks.

HMAC proves integrity and origin to the secret holder; it doesn't encrypt.

Sign and verify with a replay window Ran on Python 3.13.5 · mypy --strict
Python 3.13
import hashlib
import hmac
import time

SECRET = b"whsec_demo"


def signature(ts: int, body: bytes) -> str:
    msg = str(ts).encode() + b"." + body
    return hmac.new(SECRET, msg, hashlib.sha256).hexdigest()


def accept(ts: int, body: bytes, sig: str, now: int) -> bool:
    if abs(now - ts) > 300:
        return False  # too old: blocks replays
    return hmac.compare_digest(signature(ts, body), sig)


now = int(time.time())
body = b'{"event":"paid","id":"evt_1"}'
sig = signature(now, body)
print(accept(now, body, sig, now))
print(accept(now, body.replace(b"paid", b"refunded"), sig, now))
print(accept(now - 600, body, signature(now - 600, body), now))

Common mistakes

  • Comparing signatures with ==.
  • Signing the parsed object rather than the raw bytes.
  • No timestamp, so replays work forever.

Senior take & follow-ups

HMAC over timestamp + raw body, constant-time compare, time window, event ids.

Follow-up questions

Think of your answer first, then open the hint.

  1. HMAC or a plain hash?

    Show hint

    A plain hash proves nothing; anyone can compute it.

  2. When use Ed25519?

    Show hint

    When many parties verify and none should be able to sign.

Suggest a fix if something here is wrong or out of date.