Skip to content
Y
YAS.SH
Developers

Webhook Security: Signatures, Retries and Replay

How to verify webhooks so a forged event can't change state.

mohamed-elsaadouni9 min readwebhookssecurityapi
Webhook Security: Signatures, Retries and Replay
Featured imageWebhook Security: Signatures, Retries and Replay

Webhook Security: Signatures, Retries and Replay

A webhook is how a service tells you "something happened" — a payment, a signup, an event. Your endpoint then acts on that notification. That's powerful, and it's exactly why webhooks are a favorite target: if your endpoint trusts any request, an attacker can POST a fake event to trigger an action you never intended.

This guide covers the three protections every webhook receiver needs: signature verification, a timestamp window (to stop replay), and idempotency (so retries and replays can't double-process).

Why signatures are non-negotiable

Webhooks are plain HTTP POSTs. Nothing stops an attacker from crafting a request that looks like it came from your provider. If your endpoint doesn't verify the sender, a forged event could, for example:

  • Mark a subscription as paid when it isn't.
  • Trigger a "cancel" you never requested.
  • Create or delete a resource.

Signature verification closes this: the provider signs the payload with a shared secret, and you recompute and compare. Only a request signed with the correct secret is trusted.

Step 1: Verify the signature

The common pattern (used by Stripe, GitHub, and most providers):

  • The provider sends a signature header, e.g. X-Signature: sha256=<hmac>.
  • You compute an HMAC-SHA256 of the raw request body using the shared secret.
  • You compare it to the provided signature.

Two critical details:

  1. Sign the raw body, not the parsed JSON. If you parse and re-serialize, the bytes change and the signature won't match (or, worse, could be bypassed).
  2. Compare in constant time (timingSafeEqual), not with ==. A timing-safe comparison prevents timing side-channel attacks.
expected = hmac_sha256(secret, rawBody)
received = signatureHeader
if !constantTimeEquals(expected, received): reject

Step 2: Add a timestamp window (stop replay)

Signatures verify authenticity but not freshness. An attacker can capture a valid signed request and replay it later — e.g. re-sending a "payment succeeded" event to double-process it.

Protect against this by including a timestamp in the signed payload and enforcing a window:

  • The provider includes a timestamp (e.g. in the signature header).
  • You reject the request if the timestamp is too old (older than your tolerance, typically 5 minutes).
  • The timestamp must be part of what's signed, so an attacker can't change it.
if abs(now - signedTimestamp) > 300s: reject  # replay

Step 3: Make it idempotent

Even with a valid, fresh signature, retries are normal — providers retry on failures, and network issues can cause duplicates. Add idempotency so the same event isn't processed twice.

The pattern:

  • Each event carries a unique event ID (the provider's event ID).
  • You record processed event IDs (in a DB).
  • If an event ID was already processed, return success without doing the work again.

This protects against both retries and any residual replay, and pairs with idempotency keys.

The complete verification flow

1. Read the raw body (don't parse yet).
2. Read the signature header + timestamp.
3. If timestamp is outside the window → reject (replay).
4. Compute HMAC-SHA256(secret, rawBody) in constant time.
5. Compare to the signature; if mismatch → reject (forgery).
6. Parse the event; check its event ID.
7. If event ID already processed → return success (idempotent).
8. Process the event atomically, record the event ID.
9. Return a fast 2xx.

Common mistakes

  • Parsing before verifying. You must verify the raw body, not the parsed object.
  • Non-constant-time comparison. Use timingSafeEqual.
  • No timestamp window. Valid signed requests can be replayed.
  • No idempotency. Provider retries double-process events.
  • Leaking the secret. The signing secret must never be exposed to the client.
  • Verifying in a way that ignores headers. Some providers sign specific header values; include the right ones.

What this looks like in practice

A well-built webhook endpoint returns a fast 2xx (acknowledge receipt) and processes asynchronously, so providers don't hammer you with retries. If processing fails, you can return an error and let the provider retry — but you should make the processing itself idempotent so retries are safe.

The takeaway

A secure webhook receiver does three things: verify the signature (constant time, on the raw body), enforce a timestamp window (stop replay), and track event IDs (idempotency against retries). Skip any one and you're open to forgery, replay, or duplicate processing.

These same principles apply whether you're the receiver building webhooks into your app or the provider — and they pair with rate limiting and API keys for a complete API security posture.

Frequently asked questions

Why do webhooks need signatures?

Webhooks arrive over HTTP, and an attacker could POST a fake event to your endpoint to trigger an action. A signature lets you verify the payload genuinely came from the provider.

What is a replay attack on a webhook?

An attacker captures a valid, signed webhook request and sends it again later to re-trigger the action (e.g. double-processing a payment). A timestamp window and idempotency keys prevent this.

How do I verify a webhook signature?

Compute an HMAC of the raw body with the provider's secret and compare it to the signature using a constant-time comparison. Also check the timestamp is recent and track processed event IDs.

Was this helpful? Share
🍪 Cookies & privacy. yas.sh uses only essential cookies to keep you signed in and remember your preferences. We do not run third-party trackers. See our cookie policy and privacy policy.
Settings