Build a small, tested webhook verification boundary with Python's standard library. Sign the exact request bytes, compare signatures safely, reject old requests, and inspect five real test outcomes.
A model service may send a result to another application after a batch job finishes. That outgoing HTTP request is a webhook: one system sends an HTTP request to another system when an event happens. The receiver needs to answer two questions before trusting the JSON body:
An ordinary API key can identify an integration when it is sent over HTTPS, but it does not bind that credential to one exact message. An HMAC (hash-based message authentication code) does. The sender combines a secret key with the request bytes to make a signature. The receiver repeats the calculation. Matching signatures show that the request was signed with the shared secret and that the signed bytes did not change. They do not identify a particular person or machine if several systems can access the same secret.
This tutorial builds that verification boundary with Python’s standard library. Our fictional forecasting service sends a compact batch summary. We will sign the exact bytes, reject changed bodies and wrong keys, add a timestamp check, and execute five deterministic tests. No web framework or live server is needed.
If HTTP requests and response codes are new to you, read Calling Web APIs in Python first. Here, we focus on the security check that runs before an application processes a request body.
You need Python 3.10 or later and basic familiarity with functions and dictionaries. The signing and verification code uses only hashlib, hmac, and json from Python’s standard library. Matplotlib is optional and only rebuilds the results chart.
Create and activate a virtual environment, then install Matplotlib if you want to reproduce the chart:
python -m venv .venv
source .venv/bin/activate
python -m pip install matplotlibThe complete build was executed with Python 3.13.2 and Matplotlib 3.11.0. You can also download the five synthetic verification cases. This original, fictional dataset was created for this tutorial and is released under CC0 1.0.
Never copy the demonstration secret into a deployed service. In production, generate a strong random secret, store it in a secret manager or protected environment variable, restrict who can read it, and define a rotation process.
Both sides must calculate the signature from identical bytes. A small change in whitespace, key order, encoding, or numeric text produces a different signature. For that reason, a receiver should verify the raw request body before parsing and rebuilding its JSON.
Our sender creates a small summary for a completed forecasting batch. separators=(",", ":") removes optional JSON spaces so the example is easy to inspect. The call to .encode() converts text into UTF-8 bytes, which HMAC requires.
import json
payload = {
"batch_id": "north-17",
"rows": 24,
"mean_prediction": 18.6,
}
body = json.dumps(payload, separators=(",", ":")).encode()
print(body)b'{"batch_id":"north-17","rows":24,"mean_prediction":18.6}'The leading b means this is a bytes value, not a Python string. An HTTP framework normally gives the receiver access to these raw body bytes. Keep them unchanged until signature verification finishes.
We will sign a timestamp and the body together. The timestamp is an integer count of seconds since the Unix epoch: 00:00:00 UTC on January 1, 1970. A period separates it from the body so the two fields have one unambiguous layout.
def canonical_message(timestamp, body):
return str(timestamp).encode("ascii") + b"." + body
timestamp = 1_749_999_955
message = canonical_message(timestamp, body)
print(message)b'1749999955.{"batch_id":"north-17","rows":24,"mean_prediction":18.6}'This agreed layout is called a canonical message: one exact byte representation that the sender and receiver both use. A real integration must document its layout. Do not guess whether a provider signs the timestamp, URL, headers, decoded text, or raw body. Follow that provider’s current documentation.
HMAC uses a shared secret plus a hash function. We will use SHA-256, a widely available secure hash algorithm. A plain SHA-256 hash of the body is not enough because anyone could change the body and calculate a new public hash. The secret key is what makes the HMAC difficult for an outsider to reproduce.
The next function accepts the timestamp, raw body, and secret. hexdigest() returns 64 hexadecimal characters that are convenient to place in an HTTP header.
import hashlib
import hmac
SECRET = b"tutorial-demo-secret-rotate-in-production"
def sign(timestamp, body, secret=SECRET):
message = canonical_message(timestamp, body)
return hmac.new(secret, message, hashlib.sha256).hexdigest()
signature = sign(timestamp, body)
print(signature)
print(len(signature))a1cda0ecd22952e2fb0bb292b10a35552fc16d6d289cecf446eb79d21a4b88cf
64The signature does not hide the payload. HMAC provides integrity (detecting a change) and message authentication (showing that the signer had the shared secret). HTTPS is still needed to encrypt the request in transit and protect its headers and body from observers.
Python’s official hmac documentation defines this API and recommends compare_digest() during verification. The official hashlib documentation lists SHA-256 among the algorithms guaranteed to be present in Python.
The receiver calculates its own expected signature. It must then compare that value with the untrusted signature from the request header. Use hmac.compare_digest() instead of ==. It is designed to avoid content-based short-circuit behavior that can reveal information through small timing differences.
A valid signature can still be copied and sent again. This is a replay attack: an attacker repeats a previously valid request. Including the timestamp in the signed message lets the receiver reject old requests. This example accepts a five-minute window.
MAX_AGE = 300
def verify(timestamp, body, supplied_signature, now):
if abs(now - timestamp) > MAX_AGE:
return False, "stale_timestamp"
expected_signature = sign(timestamp, body)
if not hmac.compare_digest(expected_signature, supplied_signature):
return False, "bad_signature"
return True, "accepted"The timestamp check comes first, so requests outside the allowed window stop without an HMAC calculation. abs() also rejects timestamps too far in the future, which can happen because of an incorrect clock or a deliberately misleading request.
Now verify the unchanged body 45 seconds after it was signed:
now = 1_750_000_000
accepted, reason = verify(timestamp, body, signature, now)
print(accepted, reason)True acceptedThe age is inside 300 seconds, and the locally calculated signature matches. The application may now parse the JSON and validate its fields. A valid signature does not prove that the fields are sensible, permitted, or safe, so normal input validation must still follow this check.
Suppose the text 18.6 becomes 98.6 while the original signature remains attached. The receiver signs the changed bytes, gets a different result, and rejects the request.
changed_body = body.replace(b"18.6", b"98.6")
print(changed_body)
print(verify(timestamp, changed_body, signature, now))b'{"batch_id":"north-17","rows":24,"mean_prediction":98.6}'
(False, 'bad_signature')The code does not need to understand which field changed. HMAC covers the entire signed byte sequence. This is also why parsing JSON and serializing it again before verification is risky: two equivalent JSON objects can have different bytes, and the signature describes bytes rather than abstract JSON values.
An old request has a different failure reason even when its HMAC is correct. The next request is 900 seconds old, so it exceeds the five-minute limit:
old_timestamp = now - 900
old_signature = sign(old_timestamp, body)
print(verify(old_timestamp, body, old_signature, now))(False, 'stale_timestamp')Clear internal reasons help tests and monitoring. An external API may return one general 401 Unauthorized response for both cases so it does not provide unnecessary clues. Never include the expected signature or shared secret in logs or error messages.
Security checks need failure-path tests, not only one successful demonstration. The reproducible build executes five cases: a valid request, a changed body, a signature made with another secret, an old request, and an empty signature.
cases = [
("valid_request", timestamp, body, signature),
("changed_body", timestamp, changed_body, signature),
("wrong_secret", timestamp, body, sign(timestamp, body, b"another-secret")),
("old_request", old_timestamp, body, old_signature),
("empty_signature", timestamp, body, ""),
]
for case_id, sent_at, sent_body, sent_signature in cases:
accepted, reason = verify(sent_at, sent_body, sent_signature, now)
print(f"{case_id:16} accepted={str(accepted).lower():5} reason={reason}")valid_request accepted=true reason=accepted
changed_body accepted=false reason=bad_signature
wrong_secret accepted=false reason=bad_signature
old_request accepted=false reason=stale_timestamp
empty_signature accepted=false reason=bad_signatureOnly the unchanged, recent request passes. The changed body and wrong secret demonstrate two different causes that produce the same safe rejection. The empty value represents a missing or empty signature header and confirms that it cannot pass verification. The build also uses assertions, so an unexpected result makes the script fail.
The chart summarizes test coverage, not production traffic. Three bad-signature cases are deliberate variations in a tiny synthetic suite. They do not estimate how often attacks occur.
Web frameworks expose headers and the request body differently, but the order should stay consistent:
The last step closes a gap in our small example. A timestamp window rejects old replays, but it cannot stop the same valid request from being repeated several times within five minutes. Production senders should include a unique event ID in the signed content. Store each accepted ID until its replay window closes, and reject duplicates. For operations that change data or charge money, also make the handler idempotent, meaning that processing the same event twice has the same effect as processing it once.
During secret rotation, a receiver may temporarily accept signatures made with either the current secret or the previous secret. Keep that overlap short, identify which integration owns each key, and remove the previous key after senders have changed. Do not accept an unlimited list of old secrets.
The signatures differ even though the JSON looks identical. Compare the raw bytes, not printed dictionaries. Whitespace, key order, Unicode encoding, and newline differences all matter. Verify the body exactly as received instead of rebuilding it with json.dumps().
The secret is a Python string. hmac.new() requires the key and message as bytes-like values. Load a text secret from a protected setting, then encode it once with a documented encoding such as UTF-8.
The code uses a plain hash. hashlib.sha256(body) can detect accidental corruption only when its trusted digest arrives through another protected channel. It does not authenticate a webhook because an attacker can replace both the body and its plain hash. Use HMAC with a secret.
The verifier compares with ==. Use hmac.compare_digest() for cryptographic comparisons. Also keep both inputs the same type: compare two hexadecimal strings or two byte strings.
Valid requests fail after deployment. Check clock synchronization, timestamp units, and the documented signed-message format. Seconds and milliseconds differ by a factor of 1,000. Log the request age and a non-sensitive event ID, but not secrets or full signatures.
The timestamp window is treated as complete replay protection. It limits how long a captured request remains useful. It does not block duplicates inside the window. Add a signed unique event ID and a short-lived store of accepted IDs.
HMAC is treated as authorization. A correct signature identifies an integration that knows the secret. Your application must still decide whether that integration may trigger this operation, validate every field, apply size limits, and protect downstream resources.
The durable pattern is canonical bytes, keyed signature, safe comparison, freshness check:
hmac.compare_digest();You can move this verification function into a FastAPI, Flask, Django, or serverless handler later. Test it independently first. When the security boundary is a small function with fixed inputs and explicit reasons, changed bodies, stale requests, and rotation behavior remain easy to inspect.