Verify Webhook Signatures in Python, Step by Step
A webhook address is just a web address. Anyone who finds it can send it a message, and the message can claim to come from GitHub, your payment provider or anyone else. If your code trusts every delivery, a stranger can
A webhook address is just a web address. Anyone who finds it can send it a message, and the message can claim to come from GitHub, your payment provider or anyone else.
If your code trusts every delivery, a stranger can trigger your automation, fake a payment, or fill your system with junk. The fix is a signature: a small piece of proof that comes with each delivery. This guide shows how signatures work and builds a verifier in Python that you can test on your own computer.
If webhooks are new to you, start with what a webhook is and how it works. It covers the basics that I'll assume here.
What a signature is
Think of a wax seal on a letter. Only the sender has the seal, so a letter with the right seal is genuine, and a letter whose seal is broken has been opened.
A webhook signature works the same way, using a shared secret instead of wax:
- You and the service agree on a secret, a long random string that only the two of you know.
- For each delivery, the service calculates a fingerprint of the message body, mixing in the secret. The method is called HMAC. It puts the fingerprint in a header, such as
X-Hub-Signature-256. - Your code calculates the same fingerprint from the body it received, using its own copy of the secret.
- If the two match, the message is genuine and unchanged.
A stranger who doesn't know the secret can't produce a matching fingerprint. Changing even one character of the body changes it completely.
If you've read my guide to what an API is, you know that a request is made of headers and a body. The signature sits in the headers, and it covers the body.
Two common styles
Providers vary in the details, but most follow one of two patterns.
The plain style (GitHub). The header is X-Hub-Signature-256, and its value is sha256= followed by the HMAC-SHA256 of the raw body, as a hex string. GitHub also sends an older X-Hub-Signature header based on SHA-1, which it keeps only for legacy reasons. Use the SHA-256 one.
The timestamped style (Stripe). The header is Stripe-Signature, and it looks like t=1700000000,v1=<signature>. The signature covers the timestamp, a dot, and then the body. The timestamp lets you reject old deliveries, which protects against replays. Stripe's libraries use a default tolerance of 5 minutes.
Others, like Shopify, use a different header name, or encode the signature differently, for example as base64 instead of hex. Check your provider's documentation for the exact header, encoding and what is signed.
What you need
- Python 3, with nothing to install
- A secret for each webhook. The service gives you one, or lets you choose one
To receive real webhooks from the internet, your receiver also needs a public HTTPS address. For testing, everything here runs on your own computer.
Step 1: The verifier
Create webhook_verify.py:
"""Check that a webhook really came from the service that claims to have sent it."""
import hashlib
import hmac
import time
class InvalidSignature(Exception):
pass
def sign(secret, message):
"""HMAC-SHA256 of the message bytes, as a hex string."""
if not secret:
raise ValueError("no secret is set. An empty secret would let anyone forge a signature")
return hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
def same(a, b):
"""Compare two strings in constant time, so the comparison doesn't leak how much of a guess was right."""
return hmac.compare_digest(a.encode(), b.encode())
def verify_github(secret, body, header):
"""GitHub style. X-Hub-Signature-256: sha256=<hex HMAC-SHA256 of the raw body>"""
if not header or not header.startswith("sha256="):
raise InvalidSignature("the signature header is missing or malformed")
if not same("sha256=" + sign(secret, body), header):
raise InvalidSignature("the signature doesn't match")
def verify_stripe_style(secrets, body, header, tolerance=300, now=time.time):
"""Stripe style. Stripe-Signature: t=<unix time>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
secrets is a list, so a new secret can be added before an old one is retired.
"""
parts = [item.split("=", 1) for item in (header or "").split(",") if "=" in item]
times = [value for key, value in parts if key == "t"]
signatures = [value for key, value in parts if key == "v1"] # other schemes, like v0, are ignored
if len(times) != 1 or not signatures:
raise InvalidSignature("the signature header is missing or malformed")
try:
sent_at = int(times[0])
except ValueError:
raise InvalidSignature("the timestamp isn't a number")
if abs(now() - sent_at) > tolerance:
raise InvalidSignature("the timestamp is too far from the current time") # a captured delivery stops working
signed = f"{sent_at}.".encode() + body
for secret in secrets:
expected = sign(secret, signed)
if any(same(expected, candidate) for candidate in signatures):
return
raise InvalidSignature("the signature doesn't match")
Here's what each part does.
-
sign()calculates the HMAC-SHA256 of some bytes. It refuses an empty secret, and that's deliberate. If a missing setting turns into an empty secret, anyone could calculate a matching signature using that empty key, and the check would pass for attackers. -
same()compares two signatures in constant time, using Python'shmac.compare_digest. A normal==can stop at the first different character, and in theory the time it takes can reveal how much of a guess was right. A constant-time comparison takes the same time either way. -
verify_github()checks the header has thesha256=prefix, calculates the signature of the raw body, and compares. -
verify_stripe_style()reads the timestamp and thev1signatures from the header, rejects deliveries whose timestamp is too far from now, and checks the signature oftimestamp.body. It takes a list of secrets, so you can add a new one before retiring the old one when you rotate secrets. If a header has severalv1values, one valid one is enough, and other schemes likev0are ignored.
You can check your code against GitHub's own published test value. Their documentation gives the secret It's a Secret to Everybody, the payload Hello, World!, and the expected signature sha256=757107ea.... Save this as test_vector.py:
from webhook_verify import sign
# The published test value from GitHub's documentation
secret, payload = "It's a Secret to Everybody", b"Hello, World!"
expected = "757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17"
print("matches GitHub's documented signature:", sign(secret, payload) == expected)
It prints:
matches GitHub's documented signature: True
If that says False, something is wrong with how you calculate the signature, and it's much easier to find out here than from a real delivery.
Step 2: The receiver
Now a small web server that uses the verifier. Create receiver.py:
import json
import logging
import os
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from webhook_verify import InvalidSignature, verify_github, verify_stripe_style
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
MAX_BODY = 1_000_000 # bytes. Refuse anything bigger without reading it
# Secrets come from the environment, never from the code. The Stripe-style one can hold several, separated by commas.
GITHUB_SECRET = os.environ.get("GITHUB_WEBHOOK_SECRET", "")
STRIPE_SECRETS = [s for s in os.environ.get("STRIPE_WEBHOOK_SECRET", "").split(",") if s]
class Receiver(BaseHTTPRequestHandler):
def reply(self, code, text):
self.send_response(code)
self.send_header("Content-Length", str(len(text)))
self.end_headers()
self.wfile.write(text.encode())
def do_POST(self):
length = int(self.headers.get("Content-Length") or 0)
if length > MAX_BODY:
return self.reply(413, "too large")
body = self.rfile.read(length) # the raw bytes, exactly as they were sent
try:
if self.path == "/github":
verify_github(GITHUB_SECRET, body, self.headers.get("X-Hub-Signature-256"))
elif self.path == "/stripe":
verify_stripe_style(STRIPE_SECRETS, body, self.headers.get("Stripe-Signature"))
else:
return self.reply(404, "not found")
except InvalidSignature as error:
logging.warning("Rejected a delivery to %s: %s", self.path, error) # the reason goes in the log, not in the reply
return self.reply(401, "invalid signature")
try:
event = json.loads(body) # only now is it safe to read what the body says
except ValueError:
return self.reply(400, "not JSON")
logging.info("Accepted a delivery to %s: %s", self.path, str(event)[:80])
self.reply(200, "ok") # answer quickly, and do any slow work afterwards
def log_message(self, *args):
pass
if __name__ == "__main__":
if not GITHUB_SECRET or not STRIPE_SECRETS:
sys.exit("Set GITHUB_WEBHOOK_SECRET and STRIPE_WEBHOOK_SECRET first.")
print("Listening on http://127.0.0.1:8000")
ThreadingHTTPServer(("127.0.0.1", 8000), Receiver).serve_forever()
The order of operations matters.
- Read the raw bytes first. The signature covers the exact bytes that were sent, so the code reads them before doing anything else with them.
- Limit the size. A body over 1 MB is refused without being read.
- Verify before you trust. Nothing in the body is read until the signature checks out.
-
Say little, log a lot. A rejected delivery gets a plain
invalid signaturereply, so an attacker learns nothing. The real reason goes in your log. - Parse after verifying. Only now is the JSON read.
- Answer quickly. Providers expect a fast reply. Do slow work afterwards.
The secrets come from environment variables, not from the code, and the server refuses to start without them.
Step 3: Try it
In one terminal, start the receiver with two secrets:
GITHUB_WEBHOOK_SECRET=test-github-secret STRIPE_WEBHOOK_SECRET=test-stripe-secret python3 receiver.py
In another, send it some test deliveries. Save this as send_test.py:
import json
import time
import urllib.error
import urllib.request
from webhook_verify import sign
URL = "http://127.0.0.1:8000"
GITHUB_SECRET, STRIPE_SECRET = "test-github-secret", "test-stripe-secret" # use the same values you gave the receiver
def send(path, body, headers):
request = urllib.request.Request(URL + path, data=body, headers=headers)
try:
with urllib.request.urlopen(request) as response:
return response.status
except urllib.error.HTTPError as error:
return error.code
body = json.dumps({"action": "opened", "issue": 42}).encode()
now = int(time.time())
print("GitHub style, correct signature: ", send("/github", body, {"X-Hub-Signature-256": "sha256=" + sign(GITHUB_SECRET, body)}))
print("GitHub style, body changed: ", send("/github", body + b" ", {"X-Hub-Signature-256": "sha256=" + sign(GITHUB_SECRET, body)}))
print("GitHub style, no signature: ", send("/github", body, {}))
print("Stripe style, correct signature: ", send("/stripe", body, {"Stripe-Signature": f"t={now},v1=" + sign(STRIPE_SECRET, f"{now}.".encode() + body)}))
old = now - 3600 # a delivery that was captured an hour ago and is being replayed
print("Stripe style, an hour old: ", send("/stripe", body, {"Stripe-Signature": f"t={old},v1=" + sign(STRIPE_SECRET, f"{old}.".encode() + body)}))
Run python3 send_test.py. You should see:
GitHub style, correct signature: 200
GitHub style, body changed: 401
GitHub style, no signature: 401
Stripe style, correct signature: 200
Stripe style, an hour old: 401
The correct deliveries get 200. Changing the body by a single space, leaving out the signature, or sending an hour-old Stripe-style delivery all get 401.
Back in the receiver's terminal, the log shows the real reasons (timestamps left out):
INFO Accepted a delivery to /github: {'action': 'opened', 'issue': 42}
WARNING Rejected a delivery to /github: the signature doesn't match
WARNING Rejected a delivery to /github: the signature header is missing or malformed
INFO Accepted a delivery to /stripe: {'action': 'opened', 'issue': 42}
WARNING Rejected a delivery to /stripe: the timestamp is too far from the current time
Notice that the replies say nothing about why, while the log does.
Why signatures fail
Nearly every "signature mismatch" problem comes from one of these.
You parsed the body first. A framework may read the JSON, and you re-encode it before checking. That changes spacing, key order or special characters, so the bytes no longer match what was signed. For example, {"name": "ZoΓ«", "n": 1} becomes {"name": "Zo\u00eb", "n": 1} after a round trip. Always verify against the raw bytes.
The secret has a stray character. A trailing newline or space in an environment variable or a copied value gives a different secret.
The wrong thing was signed. Check what your provider signs. Some sign only the body. Others sign a timestamp plus the body, and some include the URL.
The encoding differs. Some providers send the signature as base64 instead of hex. A few give you a secret that must be decoded from hex or base64 to bytes before use. Hashing the text of the secret instead of the decoded bytes gives a completely different result.
The wrong header. Header names aren't case-sensitive, but they must be the right ones. For GitHub, use X-Hub-Signature-256, not the older SHA-1 header.
The clock is wrong. With timestamped signatures, a server clock that has drifted can reject real deliveries. Keep the clock accurate with NTP.
A middle layer changed the body. Some frameworks, proxies or gateways alter the body before your code sees it. Make sure your code gets the original bytes.
To debug, calculate the signature by hand from the exact bytes you received. Log the body's length and a hash of it, but not the secret, and not the body itself if it holds personal data.
A valid signature isn't the whole story
There's a gap that catches people out: a replay. An attacker who captures a real delivery, with its genuine signature, can send it again later. The signature is still valid, because the body hasn't changed.
- The plain style has no timestamp, so nothing in the signature says when it was sent.
- The timestamped style limits it. The tolerance window rejects old deliveries, but a replay inside the window still passes. I tested exactly that. The same delivery verified three times in a row.
Replays aren't the only source of repeats. Providers resend a delivery when your server was slow or returned an error, so getting the same event twice is normal.
The fix is to remember which deliveries you've handled. GitHub sends a unique ID with each delivery in the X-GitHub-Delivery header, and Stripe events have an id. Store the ID, and if you see it again, return 200 without doing the work twice. Handling duplicates properly is a topic of its own, but this one habit prevents most of the damage.
Other protections
- Use HTTPS. A signature proves who sent a message. It doesn't hide the contents.
- Keep secrets out of code. Use environment variables or your platform's secret storage.
- Rotate secrets. Add the new secret to the list, switch the provider over, then remove the old one. The Stripe-style verifier supports this.
- Limit what a request can do. The size limit above is one. Rate limits are another.
- Treat IP allow-lists as a bonus. Some providers publish their IP ranges. Using them as an extra layer is fine, but IP addresses are weaker than signatures.
- Don't log secrets or full bodies. The body may hold private details.
Keep an eye on it
A webhook receiver is a small service that has to stay up. If it goes down, providers retry for a while, and after that you lose events. A simple check of your endpoint helps, and I show how in my free Python uptime monitor guide.
Rejected deliveries are worth watching, too. A handful now and then is normal noise. A sudden burst means someone is probing your endpoint, or your provider changed something, and either way you want to know. I explain how to set up alerts like that in how to monitor your automations when something breaks.
Quick answers
Why not just keep the URL secret?
URLs leak through logs, screenshots and shared links, and anyone who finds it can post to it. A signature proves each delivery individually.
Is HMAC the same as encryption?
No. HMAC proves who sent a message and that it wasn't changed. It doesn't hide the contents.
Why a constant-time comparison?
It stops an attacker from learning about a signature by measuring how long your check takes. It costs nothing, so there's no reason not to use it.
What if my provider doesn't sign webhooks?
Some don't. In that case, add your own secret to the URL as a long random token, check that, and keep the endpoint's power small. It's weaker than a signature, so treat what it receives with suspicion.
Can GitHub Actions receive webhooks?
Not directly. A workflow runs when something triggers it, and it can't sit and listen for incoming requests. A small server or a cloud function can.
How long should the time window be?
Stripe's libraries default to 5 minutes, which allows for clock drift without leaving a wide gap. Shorter is stricter, and needs accurate clocks.
What status code should I send?
200 (or any 2xx) when you've accepted the delivery. 401 for a bad signature. Providers usually retry on errors, so don't return an error for a duplicate you've already handled.
The short version
Treat every webhook as untrusted until the signature checks out. Verify the raw bytes, compare in constant time, refuse an empty secret, check the timestamp if there is one, and remember the delivery IDs you've seen.
Test against the published example first, then against your real provider.
You can find more practical automation guides on Procwire.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.