Skip to content
ecdsa.com

HMAC-SHA256 sign/verify · Python

How to sign and verify a message with HMAC-SHA256 in Python

To sign and verify a message with HMAC-SHA256 in Python, use the standard library: hmac.new(secret, body, hashlib.sha256) computes the MAC and hmac.compare_digest compares it in constant time. The typical case is a webhook header such as GitHub's X-Hub-Signature-256; this recipe checks GitHub's documented example and verifies deliveries in a Flask route.

Tested with Python 3.14.8 (hmac and hashlib from the standard library) and Flask 3.1.3 (webhook route, exercised with Flask's test client).

webhook.py — sign and verify a sha256=<hex> header
import hashlib
import 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())
app.py — a Flask route that verifies the raw body before parsing JSON
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

  1. hmac.new(secret, raw_body, hashlib.sha256).hexdigest() gives the 64 hex digits; the header value is sha256= followed by them.
  2. hmac.compare_digest takes 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.
  3. Both sides of the comparison are encoded to bytes first, so any header value — including non-ASCII text a client made up — gives False instead of an exception.
  4. In Flask, request.get_data() returns the body bytes exactly as received; verify them, then call request.get_json(). The run feeds GitHub's example (Hello, World!, secret It's a Secret to Everybody) and gets sha256=757107ea…3e17.
  5. The secret comes from the WEBHOOK_SECRET environment 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 raises TypeError: Missing required argument 'digestmod'. (Python 3.8 and later); always pass hashlib.sha256.
  • hmac.compare_digest accepts two str only if both are ASCII — compare_digest("sha256=é", header) raises TypeError: comparing strings with non-ASCII characters is not supported — and refuses to mix str with bytes. Encode both sides, as verify does.
  • 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 with 401. Verify request.get_data(), never a re-serialized request.json.
  • A missing header (None) must fail the request, not skip the check — verify returns False for it.

Related recipes