Webhook Signature Verification: How It Works and Why It Fails
How webhook HMAC signatures from Stripe, GitHub, Slack and Svix work, the raw body and timestamp rules, and the mistakes that make verification fail.
A webhook is an HTTP request a provider sends to your server when something happens: a payment succeeds, a pull request opens, a user signs up. Your endpoint reads the event and acts on it. It marks an invoice paid, ships an order, grants access.
That endpoint is a public URL. Anyone who learns it can send it a request that looks exactly like the provider's. If the server trusts the body, an attacker can post a fake invoice.paid and receive goods without paying. Signature verification is the control that stops this, and it is easy to get subtly wrong.
What a webhook signature proves
The provider and your server share a secret, which you set or copy from the provider's dashboard when you create the endpoint. For each delivery, the provider computes an HMAC of the request with that secret and puts the result in a header. Your server computes the same HMAC over what it received and compares the two.
A match proves two things. The sender knew the secret, so it was the provider (or someone who stole the secret). And the signed content was not changed in transit, because any change produces a different HMAC.
A signature alone does not prove the request is fresh. A captured request with a valid signature stays valid forever unless the signed content includes a timestamp you check. That is why most modern schemes sign a timestamp too.
How the major providers sign
The idea is the same everywhere. The details differ, and those details are where verification breaks.
| Provider | Header | What is signed | Hash and encoding | Replay window |
|---|---|---|---|---|
| Stripe | Stripe-Signature: t=...,v1=... | timestamp.body | HMAC-SHA256, hex | 5 minutes by default in Stripe's libraries |
| GitHub | X-Hub-Signature-256: sha256=... | body | HMAC-SHA256, hex | None signed; dedupe on X-GitHub-Delivery |
| Slack | X-Slack-Signature: v0=... plus X-Slack-Request-Timestamp | v0:timestamp:body | HMAC-SHA256, hex | Slack recommends rejecting requests older than 5 minutes |
| Standard Webhooks and Svix | webhook-signature: v1,... plus webhook-id and webhook-timestamp | id.timestamp.body | HMAC-SHA256, base64 | Set by the receiver, commonly 5 minutes |
| Shopify | X-Shopify-Hmac-SHA256 | body | HMAC-SHA256, base64 | None signed; dedupe on X-Shopify-Webhook-Id |
| Twilio | X-Twilio-Signature | full URL plus sorted form parameters (for JSON bodies, the URL with a bodySHA256 parameter) | HMAC-SHA1, base64 | None signed |
Two details in that table trip up a lot of code. Stripe uses its whsec_ secret as the key exactly as shown, prefix included. Standard Webhooks also uses a whsec_ prefix, but there the key is the base64-decoded bytes after the prefix. Code copied from one provider to the other fails every time.
You can check any of these schemes against a real delivery with our webhook signature checker. It rebuilds the exact signed string, shows hidden newlines, and compares in your browser without sending the secret anywhere.
Verification step by step
A correct handler does the following, in this order:
- Read the raw body bytes before any JSON parser touches them.
- Read the signature header and, if the scheme has one, the timestamp and message id.
- Rebuild the signed string exactly as the provider documents it.
- Compute the HMAC with your endpoint secret and the documented hash.
- Compare in constant time against every signature of the current scheme in the header (
v1for Stripe and Standard Webhooks) and ignore any other scheme. Both may send severalv1signatures during a secret rotation, and any one matching is valid. - Reject stale timestamps outside your window.
- Dedupe on the event id, so a replay inside the window or a provider retry does nothing the second time.
- Only then parse the JSON and act on it.
Here is a minimal Node.js version for GitHub:
import crypto from "node:crypto";
export function verifyGitHub(rawBody, header, secret) {
const given = Buffer.from(String(header).replace(/^sha256=/, ""), "hex");
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest();
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}Where the provider ships a verifier in its SDK, such as Stripe's constructEvent, use it in production. It handles header parsing, multiple signatures and the timestamp window.
Why verification fails
When a signature that should match does not, the cause is almost always one of these.
| Mistake | What happened | Fix |
|---|---|---|
| Body re-serialized | A framework parsed the JSON, then your code called JSON.stringify on the object. Spacing and key order changed. | Capture the raw body on the webhook route, for example express.raw({ type: "application/json" }). |
| Trailing newline | A test tool, editor or heredoc added \n to the body you are testing with. | Send the body with curl --data-binary and compare byte lengths. |
| Line endings changed | Windows \r\n became \n, or the reverse. | Treat the body as bytes, never as lines. |
| Wrong secret | Test-mode secret against live events, or one endpoint's secret on another endpoint. | Stripe and Standard Webhooks issue one secret per endpoint; Shopify signs with the app client secret and Twilio with the account auth token. Check you have the right one for this endpoint and mode. |
whsec_ handling | Prefix stripped for Stripe, or kept as text for Standard Webhooks. | Follow the provider's key rule exactly. |
| Encoding mix-up | Hex compared against base64, or uppercase hex against lowercase. | Decode both sides to bytes, then compare. |
| Timestamp units | Milliseconds used where the scheme signs seconds. | Use the timestamp string from the header unchanged. |
| URL mismatch | For Twilio and Square, a proxy changed the scheme, host or port in the URL you sign. | Sign the public URL the provider called. |

Take one failing delivery, its header and your secret, and try each variant: minified body, body without the final newline, the other secret format. The variant that matches names the bug. The webhook signature checker runs these tests automatically.
Replay and duplicate events
Stripe, Shopify, Slack and most Standard Webhooks senders retry deliveries that time out or return an error, so the same event can arrive more than once. GitHub does not retry on its own, but a manual redelivery resends the event with the same delivery id. Your handler has to be idempotent whatever an attacker does.
Store each processed event id with a unique constraint before acting on it. If the insert fails because the id exists, return 200 and stop. This one table blocks replays inside the timestamp window, makes provider retries safe, and gives you an audit trail of every event that changed state.
For schemes that sign no timestamp, such as GitHub and Shopify, event-id dedupe is the only replay control you have. Keep the ids for as long as you can, ideally indefinitely: without a signed timestamp, a captured request stays valid forever.
Secrets and rotation
A webhook secret is a credential. Anyone holding it can forge events your server will trust.
- Keep it in a secret manager or the deploy environment, never in the repository. If one has leaked, our guide to leaked API keys covers the response.
- Use one secret per endpoint where the provider allows it, so a leak in staging does not open production.
- Rotate on a schedule and after any exposure. Stripe and Standard Webhooks can sign with old and new secrets during a rollover window, which is why the header can carry several signatures.
- Never log the secret, and avoid logging full signature headers alongside bodies.
How to defend
- Verify every delivery with the provider's documented scheme, over the raw body, in constant time.
- Enforce a timestamp window where the scheme signs one. Five minutes is the common default.
- Dedupe on the event id with a unique constraint before taking action.
- Fetch, then trust. For high-value events such as payments, call the provider's API for the object id in the event and act on what the API returns. A forged body then cannot change the outcome.
- Return 2xx fast and do the work on a queue, so slow processing does not cause retries and duplicates.
- Restrict the endpoint where the provider publishes source IP ranges, as a second layer. Stripe publishes a list and GitHub exposes its hook ranges through its
/metaAPI; Twilio, Slack and Shopify do not publish fixed webhook ranges. Never rely on IP allow-lists alone. - Test the failure path. Send a request with a wrong signature in CI and assert a 400 or 401, and do the same for a stale timestamp and a repeated event id.
Webhooks are part of your API surface. The same threat model applies as for any other endpoint, which our API security guide walks through.
Related guides
Sources & further reading
- Receive Stripe events in your webhook endpoint: verify webhook signatures (Stripe)
- Validating webhook deliveries (GitHub Docs)
- Standard Webhooks specification (Standard Webhooks)