Why can verifying a webhook signature after decoding the body to str fail?
answer
- The digest is over octets, not characters
- The round trip loses the original spelling
- Only some payloads differ, hence intermittent
- Check before you convert
- Keep the raw body the transport delivered
basics
~20 sA signature is computed over octets, and decoding a body to str then re-encoding it is not guaranteed to reproduce the exact octets that arrived. Verify against the raw bytes the connection delivered, then decode afterwards with an explicitly named encoding.
solid answer
~40 sThe sender signed a specific octet sequence. Once you decode that body to `str`, the original octets are gone; re-encoding gives you *an* encoding of the same text, which is byte-identical only if nothing in between changed the representation. Anything that re-serialises, strips a byte-order mark, applies a locale-dependent formatting step, or parses and re-dumps the JSON produces different octets and a mismatched digest — intermittently, because only some payloads differ. Worse, decoding can raise `UnicodeDecodeError` before you ever reach the check, turning a would-be rejection into a 500. The fix is a boundary rule: keep the raw `bytes` the framework received, compute the HMAC on those with `hmac.new` and compare with `hmac.compare_digest`, and only then decode or hand the bytes straight to `json.loads`, which accepts them.
code
python · 13 linesimport hashlib
import hmac
import json
secret = b"shared-secret"
raw = b'{"event":"caf\xc3\xa9"}' # exactly what the transport delivered
sent_sig = hmac.new(secret, raw, hashlib.sha256).hexdigest()
expected = hmac.new(secret, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sent_sig):
raise ValueError("bad signature")
print(json.loads(raw)) # json.loads takes bytes directlygo deeper
Take away the rule rather than the scenario: a hash or signature is computed over bytes, so hash the bytes you received and do not convert them to str first. Knowing that a request body arrives as bytes is the part to remember.
Explain why the round trip is not an identity: re-serialisation, byte-order marks and reformatting all change the octets while leaving the text equal. Be able to name hmac.new and hmac.compare_digest and to say that json.loads accepts bytes.
An interviewer expects the production diagnosis: intermittent failures correlating with non-ASCII payloads, comparing hashed length against the sender's reported length, and walking the middleware chain to find the layer that produced text. Order the operations so unverified input never reaches a decode.
Own the contract between producer and receiver: what is signed, what the canonical representation is, how secrets rotate, and which layer is allowed to touch a body before verification. The tradeoff is a strict raw-body rule against the convenience of middleware that normalises requests for everyone.
## What actually gets signed A webhook signature is an HMAC over the request body as a **sequence of octets**. The sender hashed exactly the octets it put on the wire; nothing about characters, Unicode or your codec choice enters into it. Your receiver must hash exactly the octets it took off the wire. That is the whole content of the bug. `bytes` -> `str` -> `bytes` is not an identity function in practice, even when both steps use the same codec, because the middle value is *text* and text has no memory of how it was spelled. Everything the round trip loses is a place a digest can diverge: - A byte-order mark at the start of the payload is decoded into a code point and often stripped or re-emitted differently. - A layer that parses the JSON and re-serialises it changes key order, whitespace and escaping. The object is equal; the octets are not. - A helper that formats or logs the body through a locale-dependent representation, then passes the reformatted text on, replaces separators and quoting. - The producer sent text that is valid in its encoding but not in the one you assumed, so decoding raises before the check runs at all. The symptom is characteristic: verification passes for most traffic and fails for a minority. The failing minority is precisely the payloads containing something non-ASCII, which are also the payloads your fixtures never contain. On an 11-person team the first three engineers to look at it will each conclude the shared secret is out of sync, because that is the failure mode everyone has seen before. ## Why the transport hands you bytes in the first place Sockets and binary-mode files return `bytes` because octets are what the medium carries and stores. `socket.socket.recv` cannot know the peer's encoding — the wire format does not carry it inside the payload. At best it is declared out of band, in a `Content-Type` charset parameter, and that declaration is a claim by the sender rather than a fact about the octets. Framework request objects therefore keep a raw body available, and it is that value, not the parsed or decoded one, that a signature check must consume. ## The boundary rule ```python import hashlib import hmac import json def handle(raw: bytes, header_sig: str, secret: bytes) -> dict: expected = hmac.new(secret, raw, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, header_sig): raise ValueError("bad signature") return json.loads(raw) # json.loads accepts bytes directly ``` Three things are load-bearing. First, `raw` is the untouched body; capture it before any middleware can normalise it, and if a framework only exposes a stream, read it once and keep it. Second, `hmac.compare_digest` compares in constant time so the comparison itself does not leak. Third, the decode either does not happen at all — `json.loads` takes `bytes` and applies the JSON specification's own UTF-8 rule — or it happens *after* verification, so malformed input becomes a clean rejection rather than an exception on an unverified payload. ## Ordering matters as much as the type Decoding before verifying is a second, subtler defect. An attacker or a broken producer can send octets that are not valid in your assumed codec; if the decode runs first, the request dies in a codec exception, on a path where you have not yet established the payload is even from a legitimate sender. Verifying first means unauthenticated traffic is rejected cheaply and every codec error you do see comes from a sender who holds the secret, which makes the error actionable rather than noise. ## Diagnosing an existing receiver When a receiver is already failing intermittently, do not start with the secret. Log the length of the body you hashed alongside the length the sender reports, and the first bytes of each. A length mismatch on exactly the non-ASCII requests is conclusive: something between the socket and your hash re-encoded the payload. Then walk the path from the framework's raw body to your check and find the layer that produced text. It is usually a helper written for a legitimate reason — logging, validation, size limits — that returned its reformatted value instead of the original. ## What an interviewer is checking That you understand a digest is a function of octets, that you know where the `bytes`/`str` boundary sits in a web application, and that you can order the operations correctly under adversarial input. It is a fine proxy for whether someone has actually operated a receiver rather than only written one.
- Why should the signature check run before the body is decoded rather than after?Because a decode on unverified input can raise on octets that are simply invalid in your assumed codec, turning an unauthenticated request into a server error on a path that has established nothing about the sender. Verifying first rejects untrusted traffic cheaply, and any codec error you then see comes from a sender holding the secret, which makes it a real signal.
- Where would you capture the raw body in a web application?As early as possible — the framework's raw body attribute or the request stream, read once and stored — before any middleware that logs, validates or normalises it. If a layer downstream needs text, let it decode its own copy; the value used for hashing must be the octets as received and must not be re-derived from a parsed representation.
- How would you confirm this diagnosis on a receiver that is already failing?Compare the length of the body you hashed against the length the sender reports, plus the leading octets of each, on both a passing and a failing request. A length difference that appears only on non-ASCII payloads proves something between the socket and the hash re-encoded the body, which points at a specific middleware layer rather than at the shared secret.
saying these in an interview costs you the question
- Assumes decode then encode always reproduces the same octets
- Computes the digest over a re-serialised JSON object
- Decodes the body before checking the signature
- Blames the shared secret for an intermittent mismatch
- Compares digests with == instead of hmac.compare_digest
- Stores the body as str and re-encodes it for verification