DEV Community

Stephano kambeta
Stephano kambeta

Posted on

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 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:

  1. You and the service agree on a secret, a long random string that only the two of you know.
  2. 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.
  3. Your code calculates the same fingerprint from the body it received, using its own copy of the secret.
  4. 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")
Enter fullscreen mode Exit fullscreen mode

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's hmac.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 the sha256= prefix, calculates the signature of the raw body, and compares.
  • verify_stripe_style() reads the timestamp and the v1 signatures from the header, rejects deliveries whose timestamp is too far from now, and checks the signature of timestamp.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 several v1 values, one valid one is enough, and other schemes like v0 are 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)
Enter fullscreen mode Exit fullscreen mode

It prints:

matches GitHub's documented signature: True
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

The order of operations matters.

  1. 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.
  2. Limit the size. A body over 1 MB is refused without being read.
  3. Verify before you trust. Nothing in the body is read until the signature checks out.
  4. Say little, log a lot. A rejected delivery gets a plain invalid signature reply, so an attacker learns nothing. The real reason goes in your log.
  5. Parse after verifying. Only now is the JSON read.
  6. 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
Enter fullscreen mode Exit fullscreen mode

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)}))
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)