import hashlibimport hmac def sign(secret: bytes, raw_body: bytes) -> str: """Sender: HMAC-SHA256 over the exact body bytes, sent as "sha256=<hex>".""" return "sha256=" + hmac.new(secret, raw_body, hashlib.sha256).hexdigest() def verify(secret: bytes, raw_body: bytes, header: str | None) -> bool: """Receiver: recompute over the raw body and compare in constant time.""" if header is None: return False # a missing header fails, it never skips the check # bytes on both sides: compare_digest raises TypeError for non-ASCII str return hmac.compare_digest(sign(secret, raw_body).encode(), header.encode()) import os from flask import Flask, abort, request from webhook import verify app = Flask(__name__)SECRET = os.environ["WEBHOOK_SECRET"].encode() # fails at start-up when unset @app.post("/webhook")def webhook(): raw_body = request.get_data() # the bytes as received, before any JSON parsing if not verify(SECRET, raw_body, request.headers.get("X-Hub-Signature-256")): abort(401) event = request.get_json() return f"accepted {event['action']} #{event['number']}\n" How it works
hmac.new(secret, raw_body, hashlib.sha256).hexdigest()gives the 64 hex digits; the header value issha256=followed by them.hmac.compare_digesttakes as long for a forged MAC that matches in 63 digits as for one that matches in none, unlike==, which stops at the first difference.- Both sides of the comparison are encoded to bytes first, so any header value — including non-ASCII text a client made up — gives
Falseinstead of an exception. - In Flask,
request.get_data()returns the body bytes exactly as received; verify them, then callrequest.get_json(). The run feeds GitHub's example (Hello, World!, secretIt's a Secret to Everybody) and getssha256=757107ea…3e17. - The secret comes from the
WEBHOOK_SECRETenvironment variable;os.environ[...]makes the app fail at start-up when it is missing instead of signing with an empty key.
Gotchas
hmac.new(key, msg)without a hash raisesTypeError: Missing required argument 'digestmod'.(Python 3.8 and later); always passhashlib.sha256.hmac.compare_digestaccepts twostronly if both are ASCII —compare_digest("sha256=é", header)raisesTypeError: comparing strings with non-ASCII characters is not supported— and refuses to mixstrwithbytes. Encode both sides, asverifydoes.- The MAC covers bytes, not JSON.
json.dumps(json.loads(body))turns the provider's pretty-printed body into a different byte string, and the route rejects it with401. Verifyrequest.get_data(), never a re-serializedrequest.json. - A missing header (
None) must fail the request, not skip the check —verifyreturnsFalsefor it.