skip to content

Signing with hmac

Proving a payload came from a holder of the key: a keyed digest over a canonical message, verifying an inbound webhook signature, and why a plain digest of key plus message is not the same thing.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

3

How do you verify an inbound webhook's HMAC signature header in Python?

level: middleimportance: must knowfreq 55%

answer

  1. Something about the bytes before parsing
  2. Reconstruction is not the same as receipt
  3. The header needs decoding, not just splitting
  4. A tag proves who, not when
  5. Keep a list of secrets, not one

basics

~10 s

Recompute the tag with hmac.new over the exact raw request body bytes plus whatever else the sender signed, decode the header value to the same form, and compare with hmac.compare_digest. Never re-serialize parsed JSON.

solid answer

~40 s

Capture the **raw body bytes** before any parsing — re-serializing a parsed structure changes key order, whitespace and escaping, so the tag will not match. Rebuild the signed message exactly as the sender specified, which is typically a timestamp, a separator and the body. Recompute with `hmac.new(secret, signed, "sha256")`, strip any scheme prefix from the header, and decode hex or base64 to the same representation you computed. Compare with `hmac.compare_digest`, not `==`. Then check the signed timestamp against a bounded window so a captured request cannot be replayed — pick the window with the receiver's real cold-start latency in mind, because a 45-second cold start under a 30-second tolerance rejects legitimate deliveries. Accept several keys during rotation, and reject on any failure rather than falling through.

code

python · 20 lines
python
import hmac
import os


def verify(secrets, raw_body: bytes, timestamp: str, header_sig: str) -> bool:
    signed = timestamp.encode("ascii") + b"." + raw_body
    for secret in secrets:
        expected = hmac.new(secret, signed, "sha256").hexdigest()
        if hmac.compare_digest(expected, header_sig):
            return True
    return False


current = os.urandom(32)
body = b'{"event":"bounce","delivery":"9f2"}'
ts = "1757030400"
sig = hmac.new(current, ts.encode("ascii") + b"." + body, "sha256").hexdigest()

print(verify([os.urandom(32), current], body, ts, sig))
print(verify([current], body + b" ", ts, sig))

go deeper

for a junior

Recall the three steps: recompute the tag over the raw body bytes with the shared secret, compare using hmac.compare_digest, and reject anything that does not match. Remember that the body must be the bytes you received, not a re-serialized copy.

for a middle

Explain why re-serializing a parsed body breaks verification, how the signed message is assembled from the timestamp and the body, and how the header is decoded from hex or base64 before comparison. Know that a signature says nothing about freshness.

for a senior

Demonstrate the operational side: a replay window sized against the receiver's real worst-case latency such as a 45-second cold start, a rotation list of accepted secrets, failing closed on every error path, and logging outcomes without ever logging the expected tag.

for a principal

Own the boundary contract — who publishes the signing scheme, how secrets are provisioned and rotated across environments, and whether replay protection is a window, an idempotency store, or both. Decide it once for every inbound integration rather than per endpoint.

## The shape of the problem A sender — say an email-digest service posting bounce and open events to your endpoint — computes an HMAC over the request it is about to send and puts the tag in a header. Your job is to recompute the same tag with the same shared secret and decide whether to trust the request. Everything that goes wrong here goes wrong in one of four places: the bytes you sign, the value you compare against, the way you compare, and what you do about time. ## 1. Sign the bytes you received, not a reconstruction of them This is the defect that costs the most debugging hours. A web framework hands you a parsed object; you call a JSON serializer on it to get bytes back and compute the tag over those. It will not match, because serialization is not canonical: key order, separator whitespace, non-ASCII escaping and float formatting are all serializer choices, and the sender made different ones. The fix is structural — read and keep the **raw body bytes** off the request before any parsing happens, verify against those, and only then parse. In practice that means reaching for whatever your framework calls the raw or unparsed body and being careful that reading it does not consume a stream the parser later needs. The same discipline applies to the rest of the signed message. If the sender signs `timestamp + "." + body`, you must build exactly those bytes, with exactly that separator, and encode the timestamp exactly as it appeared on the wire — the ASCII digits from the header, not an integer you parsed and re-rendered. ## 2. Get the two values into the same representation Headers carry the tag in one of several spellings: lowercase hex, base64, or a scheme-prefixed form like an algorithm name, an equals sign and the hex. Some senders pack multiple tags into one header during key rotation, separated by commas. So parsing the header is a real step, not an incidental one: split off the scheme, pick the entries whose algorithm you support, and decode to the representation you computed. The cheapest way to keep this straight is to normalise both sides to raw bytes — `hmac.new(...).digest()` on your side, `bytes.fromhex(...)` or a base64 decode on theirs — because then a length mismatch or a bad character surfaces as a decode error rather than as a silent non-match. If you instead compare hex strings, be consistent about case: hex digests are lowercase, and a sender emitting uppercase will fail an exact comparison for no security reason. ## 3. Compare with compare_digest `hmac.compare_digest(a, b)` returns a bool and is the function to use in place of `==` for any secret-dependent comparison. It accepts two bytes-like values, or two `str` values provided both are ASCII-only — which hex digests are. Mixing a `str` and `bytes` raises `TypeError`, and that is a helpful accident: it catches the representation mismatch at the boundary rather than letting it look like a forged request. ## 4. Bind time, and pick the window honestly An HMAC proves the bytes came from a key holder. It says nothing about *when*, so a captured valid request replays forever unless something in the signed message pins it. That is why senders sign a timestamp alongside the body and why you must both verify the tag and check that the timestamp is inside a bounded window — a few minutes is typical. The window is a real operational decision, not a constant to copy. A receiver that scales from zero can spend a **45-second cold start** before the handler ever sees the request; a 30-second tolerance turns every scale-from-zero delivery into a rejection, which the sender then retries, which cold-starts again. Measure the receiver's actual worst-case latency and clock skew, set the window above it, and reject anything older. Where exactly-once matters, also record the delivery identifier and refuse duplicates, since a wide window plus no replay cache is a replay window by another name. ## Operational details that separate a working verifier from a good one - **Key rotation.** Hold a list of accepted secrets, verify against each, and accept if any matches. That is what lets you introduce a new secret before the sender switches and retire the old one afterwards without a flag day. - **Fail closed.** A missing header, an unparseable header, an unknown algorithm and a mismatched tag all take the same rejection path. A verifier with an early `return True` for a missing header is worse than none, because it advertises verification that is not happening. - **Say nothing useful in the error.** Return one generic rejection; do not report whether the failure was the timestamp, the parse or the tag. - **Log the outcome, never the material.** Record that verification failed and for which delivery identifier. Do not log the secret, and do not log the expected tag — an attacker who can read your logs and probe your endpoint can use a leaked expected tag directly.

  • Why does verifying against json.dumps of the parsed body usually fail?
    Because serialization is not canonical. The sender's serializer chose a key order, a separator style, an escaping policy for non-ASCII characters and a float representation; yours will choose differently, and any single difference changes the bytes and therefore the tag. Verification must run against the raw body captured off the request before parsing. Any design that requires re-serializing to verify has already lost the guarantee it is trying to provide.
  • A valid signature verifies. What has it not told you?
    It has not told you when the request was made, whether you have already processed it, or that the sender intended it for this endpoint. A captured request replays indefinitely unless a signed timestamp is checked against a bounded window and, where duplicates matter, the delivery identifier is recorded. If the sender signs only the body, the same signed payload is also replayable across environments — staging traffic accepted in production — unless the target is part of the signed message.
  • How do you rotate a webhook signing secret without dropping deliveries?
    Verify against a list rather than a single value. Add the new secret to the accepted list first, then have the sender switch to it, then remove the old one once no traffic verifies against it. Some senders send both tags in one header during the overlap, which makes the same list logic work unchanged. The failure to avoid is a hard cutover, where every in-flight retry signed with the old secret is rejected.
  • Should a rejected webhook return a detailed error saying what failed?
    No. Return one generic rejection for a missing header, an unparseable header, a stale timestamp and a bad tag alike. A caller who learns that the tag was fine but the timestamp was stale learns something about your verifier that only an attacker benefits from. Log the detail internally against the delivery identifier — but never log the secret or the expected tag, since a leaked expected tag is directly usable against the endpoint.

saying these in an interview costs you the question

  • Verifying against re-serialized JSON instead of the raw body
  • Comparing the header tag with == or a startswith check
  • Treating a valid signature as proof the request is fresh
  • Returning True when the signature header is missing
  • Hardcoding one secret so rotation requires a flag day
  • Logging the expected tag or the shared secret on failure

context

open as a page

How does Python's hmac module produce an HMAC-SHA256 tag over a payload?

level: juniorimportance: should knowfreq 45%

basics

~10 s

Build an object with hmac.new(key, message, "sha256"), then read .digest() for raw bytes or .hexdigest() for a hex string. hmac.digest(key, message, "sha256") is the one-shot form. The key and message must be bytes.

open as a page

How do you build a canonical message so an HMAC verifies on both sides?

level: seniorimportance: should knowfreq 35%

basics

~20 s

Define one exact byte encoding both sides implement: fixed field order, an unambiguous framing such as length prefixes, a pinned text encoding, and fixed formatting for numbers and timestamps. Sign those bytes, never a language object.

open as a page