Webhook signature verification: keeping fake events out

How HMAC webhook signatures work, why you need the raw body, constant-time checks and a timestamp window, with tested Python and GitHub, Stripe and Slack docs.

Data Privacy & Security7 min read

A webhook endpoint is a public URL that does something when it receives a POST. Mark an order as paid, create a ticket, kick off an AI agent, send an email. If that endpoint trusts whatever arrives, anyone who finds or guesses the URL can make your automation do those things too. Most of the time nobody tries. But the URL often ends up in logs, screenshots, browser history and shared config files, so "nobody knows it" isn't a security control.

The fix is old and well understood: the sender signs each request with a shared secret, and your endpoint checks the signature before acting. It's a few lines of code. The catch is that each of those lines has a way of going wrong that still lets the test request through. This post walks through how the checks work, using the official docs from GitHub, Stripe and Slack, and the mistakes that quietly break them.

What the sender is actually proving#

Stripe's webhook docs spell out the risk plainly: without verification, an attacker could send fake events to your endpoint "to trigger actions like fulfilling orders, granting account access, or modifying records."

Almost every big provider uses the same mechanism, an HMAC. When you register the endpoint, the provider gives you a secret. For each delivery it runs HMAC-SHA256 over the request body (sometimes with extra fields mixed in) using that secret as the key, and puts the result in a header. Your code repeats the calculation with its own copy of the secret. If the results match, the request came from someone who holds the secret and the body wasn't changed on the way.

The details differ by provider, which is why you should read the docs for each one rather than copying code between integrations:

Provider Signature header What gets signed Timestamp check
GitHub X-Hub-Signature-256 (sha256= + hex) the body none in the signature
Stripe Stripe-Signature (t=…,v1=…) timestamp.body library default 5 minutes
Slack X-Slack-Signature (v0= + hex) v0:timestamp:body 5 minutes
Standard Webhooks webhook-signature (v1, + base64) id.timestamp.body "allowable tolerance", not fixed

The GitHub, Stripe and Slack rows come from their own pages: GitHub, Stripe and Slack. The last row is the Standard Webhooks specification, an open spec that tries to get senders to agree on one format. If you're building a system that sends webhooks to your own customers, it's a sensible design to copy.

A verifier you can test against GitHub's sample values#

Here's a minimal Python version for GitHub-style signatures, and a second one for the timestamped style Slack uses. They only use the standard library.

import hashlib
import hmac
import time

def verify_github(secret: bytes, raw_body: bytes, header: str) -> bool:
    expected = "sha256=" + hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header or "")

def verify_slack(secret: bytes, raw_body: bytes, timestamp: str,
                 signature: str, tolerance: int = 300) -> bool:
    try:
        ts = int(timestamp)
    except (TypeError, ValueError):
        return False
    if abs(time.time() - ts) > tolerance:
        return False  # too old (or too far in the future): treat as a replay
    base = b"v0:" + timestamp.encode() + b":" + raw_body
    expected = "v0=" + hmac.new(secret, base, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature or "")

GitHub's validation guide publishes a test case so you can check your code before you go live. With the secret It's a Secret to Everybody and the payload Hello, World!, the header should be sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17. We ran the function above against those values and it returns True, and False for a tampered signature. Put that test case in your test suite. It catches encoding mistakes that are otherwise hard to spot.

If the provider ships an official library, use it instead. Stripe, for one, recommends its libraries over hand-rolled checks.

Mistake 1: verifying a body your framework has already changed#

This one trips people up constantly, and it's a tempting reason to "temporarily" switch verification off.

The signature is calculated over the exact bytes the sender transmitted. Many web frameworks parse JSON for you before your handler runs. If you then re-serialise that object to check the signature, the key order, spacing or number formatting can differ, and the HMAC won't match. Stripe warns that "any manipulation to the raw body of the request causes the verification to fail". Slack tells you to use the raw body "before it has been deserialized from JSON or other forms". GitHub says to make sure the payload and headers aren't modified before verification, and to handle the payload as UTF-8 if your stack applies a character encoding.

So configure the webhook route to hand you raw bytes, verify, and only then parse.

Mistake 2: comparing signatures with ==#

A normal string comparison stops at the first character that differs, so how long it takes leaks a little information about how close a guess was. That's a timing attack. GitHub's guide says "never use a plain == operator", and Stripe and the Standard Webhooks spec both call for a constant-time comparison.

In Python that's hmac.compare_digest, which is designed to avoid content-based short-circuiting. The docs for hexdigest() say to use it "instead of the == operator" whenever you compare against a digest you received from outside. Node has crypto.timingSafeEqual, and most languages have an equivalent. It costs nothing, so there's no reason to skip it.

Mistake 3: accepting a valid request twice#

A signature proves a request was genuine at some point. It doesn't prove it's new. If someone captures one real delivery, from a proxy log or a debugging tool, they can send it again and the signature still matches. That's a replay attack.

Stripe and Slack defend against this by putting a timestamp inside the signed content. Because the timestamp is signed, an attacker can't change it without breaking the signature, so your code can reject anything too old. Slack says to reject requests whose timestamp differs from local time "by more than five minutes", and Stripe's libraries default to a 5-minute tolerance. Stripe adds two practical warnings: keep your server clock in sync with NTP, and never set the tolerance to 0, because that "disables the recency check entirely".

GitHub's X-Hub-Signature-256 covers only the body, so there's no signed timestamp to check. For GitHub, and as a second layer everywhere else, keep a record of the delivery or event IDs you've processed and ignore repeats. Stripe recommends logging processed event IDs because endpoints "might occasionally receive the same event more than once", and the Standard Webhooks spec says to use webhook-id as an idempotency key. This is the same dedupe table you'd want anyway for retries. Our post on retries and idempotency covers that side in more detail.

Mistake 4: treating the secret as config, not as a credential#

The signing secret is a password for your endpoint. GitHub tells you to choose "a random string of text with high entropy" and never to hardcode it or push it to a repository. The Standard Webhooks spec suggests random secrets of 24 to 64 bytes.

Plan for rotation before you need it. Stripe lets you roll an endpoint's secret and keep the old one valid for up to 24 hours, and during that window it sends one signature per active secret. Standard Webhooks does the same thing with space-separated signatures in one header. Your verifier should accept a request if any listed signature matches a secret you currently trust, otherwise a rotation becomes an outage. Use a separate secret per endpoint and per environment too; Stripe issues different secrets for test and live mode on the same endpoint.

Signatures aren't the whole job#

A few things sit next to verification and are easy to forget.

Use HTTPS. Stripe requires it for live endpoints. The Standard Webhooks spec points out that signatures don't encrypt anything, so the payload is readable in transit without TLS.

Reply fast and do the work later. Stripe says to return a 2xx "before any complex logic that could cause a timeout" and to process events from a queue. Verify first, enqueue, return 200, then let a worker handle it.

Don't trust event ordering. Stripe doesn't guarantee events arrive in the order they were created, so a handler that assumes invoice.created always comes before invoice.paid will eventually break.

IP allowlists can help, but as an extra layer. Stripe tells you to use both: allowlist its published IP addresses and check signatures. An allowlist says where a request came from. The signature says it was made with your secret and hasn't been altered, which is the check that travels with the message.

Be careful about what the payload can cause. A verified webhook from your CRM is genuine, but the text inside it, a customer's name or a support ticket body, came from whoever typed it. If that text goes into an LLM prompt, the usual prompt injection rules still apply. At TheAILAB we treat "this request is authentic" and "this content is safe to act on" as two separate questions.

A short checklist for each new integration#

Before a webhook-driven automation goes live, check that the route reads the raw body and verifies before parsing, that the comparison is constant-time, that timestamped schemes reject anything outside a few minutes and your clock is synced, and that processed event IDs are stored so repeats are ignored. Confirm the secret lives in your secrets manager, not in code, and that your verifier accepts more than one signature so you can rotate.

Then do the test most people skip. Send the endpoint a request with a wrong signature, and one with a valid signature but an old timestamp, and confirm both get rejected and nothing downstream runs. A verifier that has only ever seen good requests hasn't really been tested.

Sources

  1. Validating webhook deliveries — GitHub Docs (checked 2026-10-24)
  2. Receive Stripe events in your webhook endpoint — Stripe Documentation (checked 2026-10-24)
  3. Verifying requests from Slack — Slack Developer Docs (checked 2026-10-24)
  4. Standard Webhooks specification — Standard Webhooks (GitHub repository) (checked 2026-10-24)
  5. hmac — Keyed-Hashing for Message Authentication — Python Software Foundation, Python 3 documentation (checked 2026-10-24)

Dealing with this in your own business?

If you want to use AI without sending sensitive data to outside services, tell us what you're working with and we'll suggest a setup.

Get in touch