A webhook endpoint is a URL that accepts POST requests from anyone who knows it. Securing one comes down to three things: verify the provider’s HMAC signature against the raw request body before you parse it, reject requests whose signed timestamp falls outside a short window so a captured event cannot be replayed, and sign the hop from your gateway to your application so the consumer can trust what it receives.
Most webhook security advice stops at the first of those. Verifying signatures is necessary, but it closes one door out of three. What follows is the whole model: who can forge an event, why a genuine signature can still be abused hours later, and what happens on the leg of the journey the provider never sees. The per-provider mechanics live in separate guides, linked where they belong.
Your webhook endpoint is a public, unauthenticated POST target
When you paste a URL into Stripe’s dashboard or a GitHub repository’s settings, you are publishing an HTTP endpoint with no login, no API key in front of it, and no session. It has to work that way: the provider cannot hold your credentials, and it needs to reach you without a human present — the delivery is a plain POST with nothing but headers to vouch for it.
That leaves the URL itself as the only thing between your system and the open internet, and a URL is a poor secret. URLs end up in access logs, error trackers, proxy configs, screenshots in support tickets, .env files committed by accident. The safe assumption is that anyone who wants your ingest URL will eventually have it, and your design should not care.
From that assumption, three distinct threats follow. Someone can send you an event the provider never sent. Someone can capture a real event and send it again. And the request your gateway forwards to your own application is, from your application’s point of view, just another unauthenticated POST from the internet. Each needs its own answer. The stakes are set by what the payloads carry, and webhook payloads are usually personal data, which makes a forged or leaked event a breach rather than a nuisance.
Threat 1: forged events
Here is the attack in full. An attacker learns your endpoint, then POSTs this to it:
POST /webhooks/stripe
Content-Type: application/json
{"type": "payment_intent.succeeded", "data": {"object": {"amount": 49900}}}
If your handler reads type, finds a matching order, marks it paid, and triggers fulfilment, the attacker has just bought something for free. No credentials were stolen. They guessed a URL and sent well-formed JSON.
Every serious webhook provider solves this the same way: a shared secret established when you create the endpoint, and a cryptographic signature on every request. The signature turns an anonymous POST into an authenticated one. Without it, “the payload says it came from Stripe” is as trustworthy as an email whose From header says it came from your bank.
How HMAC signature verification works
HMAC is a keyed hash. You and the provider both hold the same secret string. Before sending, the provider computes an HMAC digest over the exact bytes of the request, and puts that digest in a header. When the request arrives, you recompute the digest over the bytes you received, using your copy of the secret, and compare the two.
The secret never travels on the wire, which is the whole point. A matching digest proves two things at once: the sender holds the secret, and the body was not modified in transit. A single flipped byte produces a completely different digest.
Providers agree on the mechanism and disagree on nearly every detail around it. The header name differs, the encoding differs, and most importantly what gets signed differs: some sign the raw body alone, others sign a constructed string that includes a timestamp.
Table: how three common providers express the same HMAC-SHA256 mechanism differently — the header, the string that gets signed, and the encoding of the digest you compare against.
| Provider | Header | Scheme | Encoding |
|---|---|---|---|
| Stripe | Stripe-Signature | HMAC-SHA256 over t.payload | hex |
| GitHub | X-Hub-Signature-256 | HMAC-SHA256 over the raw body | hex, prefixed sha256= |
| Shopify | X-Shopify-Hmac-Sha256 | HMAC-SHA256 over the raw body | base64 |
Those differences are why “just verify the signature” is more work than it sounds across several sources. Compare a hex digest against a base64 one and it fails every time, and the failure looks identical to a genuine forgery, so you cannot tell a bug from an attack by reading your logs.
The per-provider details are covered in their own guides: verifying Stripe’s HMAC signature step by step, GitHub’s X-Hub-Signature-256 scheme, and Shopify’s base64 HMAC and the mandatory GDPR topics that every app has to handle. Stripe’s own webhook documentation and GitHub’s guide to validating webhook deliveries are the authoritative references for their respective schemes.
The three mistakes that break verification
Verification code either works or it does not, but there are three failures that look like working code and are not. If you are debugging a live mismatch right now, the six causes of a failed signature check narrows it down provider by provider.
1. Parsing the body before verifying it. Most frameworks decode JSON before your handler runs. If you re-serialize that object to compute the HMAC, you are hashing your framework’s rendering of the data, not the provider’s. Key order, whitespace, and unicode escaping will differ, and the digest will not match. Capture the raw bytes and verify those:
raw = request.raw_body # bytes, exactly as received
expected = hmac_sha256(secret, signed_payload_from(raw))
if not constant_time_equals(expected, header_signature):
reject(400)
payload = parse_json(raw) # only now
There is a second reason to keep this order. Verifying first means a hostile or malformed payload is rejected before your parser, your validation layer, and your business logic ever touch it.
2. Comparing signatures with ==. A normal string comparison returns as soon as it finds a differing character, so it takes measurably longer to reject a signature that shares a long prefix with the correct one. That timing difference is enough to reconstruct a valid digest one character at a time given enough attempts. Use the constant-time comparison your language provides — hmac.compare_digest in Python, crypto.timingSafeEqual in Node, hmac.Equal in Go.
3. Checking the signature and ignoring the timestamp. This one is not a bug in the code so much as a gap in the model, and it deserves its own section.
Threat 2: replay
A signature proves who sent a request and that nobody altered it. It says nothing about when, and it never expires on its own. A signed request captured today is still a perfectly valid signed request next month.
That matters more than it first appears, because signed requests are not well guarded once they arrive. They sit in access logs, in a proxy’s debug output, in an error tracker’s request capture, in a staging environment that mirrors production traffic. Anyone who can read one of those can take a genuine order/create request and POST it to your endpoint fifty times. Every copy verifies, because the signature genuinely is the provider’s.
The defense is to make the timestamp part of what gets signed, then refuse anything outside a narrow window. This is why Stripe’s signed string is t.payload rather than the payload alone: the timestamp is inside the HMAC, so an attacker cannot edit it without invalidating the signature. You read t, compare it against your own clock, and reject anything older than your tolerance — five minutes is the common choice, and how to pick a replay tolerance window walks through what moves that number. Providers that sign only the raw body, GitHub and Shopify among them, give you no equivalent, so for those you lean on the event ID instead.
A tolerance window narrows the attack, it does not close it. Inside the window a replay still verifies, and your delivery layer produces duplicates independently of any attacker: Webhooker’s queue is at-least-once by design, as is every provider’s retry logic. The consumer-side answer is the same in both cases. Record the provider’s event ID before you act on it, and ignore anything you have already seen. An idempotent consumer turns replay into a no-op.
Threat 3: the second hop
Say you put a gateway in front of your application, and it verifies every provider’s signature before accepting a payload. The provider-to-gateway leg is now authenticated. The gateway-to-application leg is not.
Your application is still exposing an endpoint that accepts POSTs, and that URL leaks the same way any other does. If it trusts whatever arrives because “it comes from the gateway”, you have moved the forgery problem one hop inward rather than solving it. An attacker who finds the internal endpoint can inject events that passed through no verification at all.
The answer is symmetric to the inbound case: the gateway signs what it forwards, and your application verifies with a secret only the two of them share. Webhooker signs outbound requests for exactly this reason, so the consumer checks one scheme it controls instead of implementing every provider’s dialect. That is the shape of the arrangement — per-source signature verification before a payload is accepted on the way in, one consistent signature on the way out.
Defense in depth
Signatures and timestamps carry most of the weight. These layers sit around them, and none of them is a substitute for the two above.
- HTTPS only, no exceptions. TLS is what stops the signed request being read off the wire in the first place. A signature over a plaintext request protects integrity but hands the attacker a replayable copy.
- Rotate secrets, and plan for the rotation. Store the signing secret outside your code, and accept both the old and new secret during a short overlap so rotation does not drop events. Rotate immediately if one appears in a log, a repository, or a support thread. On Stripe that overlap runs up to 24 hours and the header carries one signature per active secret — where to find and roll the signing secret covers the mechanics.
- Rate limit per source. Verification is cheap but not free, and an endpoint that accepts unlimited POSTs can be used to burn your CPU. Webhooker applies per-source rate limiting ahead of verification; the free plan ingests up to 120 requests per minute.
- Allowlist IPs where the far end supports it. Some destinations only accept traffic from known addresses. On the Team plan, Webhooker delivers from a static outbound IP you can add to that kind of allowlist. It is an extra layer, not authentication — addresses can be spoofed.
- Log rejections. A steady trickle of failed verifications is usually a misconfigured secret. A sudden spike is someone probing. You see neither unless you record the rejects.
Where verification belongs
The last decision is architectural: does each consumer verify, or does one component do it at the edge?
Verifying in every consumer scales badly. Three providers and four internal services means twelve implementations of subtly different HMAC logic, each with its own copy of a secret and its own chance of mishandling the raw body. The base64-versus-hex bug gets fixed in two of them and lives on in the rest.
The alternative is one verified entry point. Signatures are checked once, per source, against that provider’s scheme, before the payload is accepted. Everything behind that boundary consumes events already known to be genuine, and trusts a single signing scheme rather than one per vendor. That is what a gateway is for, and why you configure a verification scheme per source rather than per service.
You can get a verified ingest URL on the free plan and point one provider at it to see how that feels. What matters is not which tool sits at the edge, but that the checks happen in one place you can reason about, before anything downstream treats an event as true.
Frequently asked questions
Is HTTPS enough to secure a webhook endpoint?
No. TLS protects data in transit — it stops an observer reading or modifying the request between the provider and you. It says nothing about who sent it. Anyone on the internet can open a valid HTTPS connection to your endpoint and POST whatever they like, and the padlock will be just as green. HTTPS and signature verification solve different problems: one protects the channel, the other authenticates the sender. You need both, and neither substitutes for the other.
Should I use a shared bearer token instead of signature verification?
A static token in a header authenticates the sender, but it is weaker than an HMAC signature in two ways. The token itself travels on every request, so anyone who captures one request has the credential forever; an HMAC secret never leaves either end. And a token proves nothing about the body, so a proxy that alters the payload leaves no trace. Use a bearer token only where a provider offers no signing at all, and pair it with HTTPS and tight logging.
What if a provider doesn’t sign its webhooks?
Some smaller services still do not. You are then relying on weaker signals, so stack several: a long random path segment in the URL, a shared secret in a header the provider lets you configure, IP allowlisting if they publish their ranges, and strict schema validation on the payload. Treat those events as lower-trust — verify anything financially or operationally significant with a follow-up API call to the provider before acting on it. Never work around a missing signature by skipping verification on the sources that do sign.