Skip to content
web-securityintermediate#webhooks#hmac#api-security#appsec#replay-attacks

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.

ProviderHeaderWhat is signedHash and encodingReplay window
StripeStripe-Signature: t=...,v1=...timestamp.bodyHMAC-SHA256, hex5 minutes by default in Stripe's libraries
GitHubX-Hub-Signature-256: sha256=...bodyHMAC-SHA256, hexNone signed; dedupe on X-GitHub-Delivery
SlackX-Slack-Signature: v0=... plus X-Slack-Request-Timestampv0:timestamp:bodyHMAC-SHA256, hexSlack recommends rejecting requests older than 5 minutes
Standard Webhooks and Svixwebhook-signature: v1,... plus webhook-id and webhook-timestampid.timestamp.bodyHMAC-SHA256, base64Set by the receiver, commonly 5 minutes
ShopifyX-Shopify-Hmac-SHA256bodyHMAC-SHA256, base64None signed; dedupe on X-Shopify-Webhook-Id
TwilioX-Twilio-Signaturefull URL plus sorted form parameters (for JSON bodies, the URL with a bodySHA256 parameter)HMAC-SHA1, base64None 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:

  1. Read the raw body bytes before any JSON parser touches them.
  2. Read the signature header and, if the scheme has one, the timestamp and message id.
  3. Rebuild the signed string exactly as the provider documents it.
  4. Compute the HMAC with your endpoint secret and the documented hash.
  5. Compare in constant time against every signature of the current scheme in the header (v1 for Stripe and Standard Webhooks) and ignore any other scheme. Both may send several v1 signatures during a secret rotation, and any one matching is valid.
  6. Reject stale timestamps outside your window.
  7. Dedupe on the event id, so a replay inside the window or a provider retry does nothing the second time.
  8. 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.

MistakeWhat happenedFix
Body re-serializedA 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 newlineA 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 changedWindows \r\n became \n, or the reverse.Treat the body as bytes, never as lines.
Wrong secretTest-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_ handlingPrefix stripped for Stripe, or kept as text for Standard Webhooks.Follow the provider's key rule exactly.
Encoding mix-upHex compared against base64, or uppercase hex against lowercase.Decode both sides to bytes, then compare.
Timestamp unitsMilliseconds used where the scheme signs seconds.Use the timestamp string from the header unchanged.
URL mismatchFor Twilio and Square, a proxy changed the scheme, host or port in the URL you sign.Sign the public URL the provider called.
Illustrated cybersecurity scene for Webhook Signature Verification: How It Works and Why It Fails
Illustration for Webhook Signature Verification: How It Works and Why It Fails.
The fastest way to find which mistake it is

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

  1. Verify every delivery with the provider's documented scheme, over the raw body, in constant time.
  2. Enforce a timestamp window where the scheme signs one. Five minutes is the common default.
  3. Dedupe on the event id with a unique constraint before taking action.
  4. 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.
  5. Return 2xx fast and do the work on a queue, so slow processing does not cause retries and duplicates.
  6. 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 /meta API; Twilio, Slack and Shopify do not publish fixed webhook ranges. Never rely on IP allow-lists alone.
  7. 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.

Sources & further reading