Dandelion Labs - Ship Fast. Scale Smarter.Dandelion Labs - Ship Fast. Scale Smarter.
  • Services
  • Projects
  • Careers
  1. Home
  2. /
  3. Blog
  4. /
  5. A valid webhook signature does not stop a replay
A valid webhook signature does not stop a replay
Security

A valid webhook signature does not stop a replay

Security

2026-10-04·6 min read·Dandelion Labs

On this page

  • A valid signature proves origin, not uniqueness
  • Build the acceptance boundary in the right order
  • Make duplicate rejection atomic
  • Separate ingestion from business effects
  • Test retries, redelivery and disorder
  • What this does not change

A valid signature proves origin, not uniqueness

A webhook can be authentic and still be unsafe to process twice. Stripe's webhook documentation says its signature covers a timestamp and the raw request body, and its official libraries reject timestamps outside a default tolerance of five minutes. GitHub separately recommends using the X-GitHub-Delivery header to ensure that each delivery is processed once.

Those controls answer different questions. The HMAC answers whether someone with the shared secret signed these exact bytes. The timestamp answers whether that signature is recent enough. A durable delivery or event identifier answers whether your system has already accepted the work.

As checked on 4 October 2026, Stripe can automatically retry a live delivery for up to three days. A manual resend is available in the Dashboard for 15 days and through the CLI for 30 days. Each Stripe retry gets a new timestamp and signature, while retaining the event identity. GitHub takes the opposite approach for its delivery identifier: a requested redelivery keeps the same X-GitHub-Delivery value.

That is why "the signature passed" cannot be the last line of a webhook handler. A copied request may be replayed inside the allowed time window. A provider retry may arrive with a fresh valid signature. Either one can cause a second fulfilment, email, credit, permission grant or deployment if the business effect has no uniqueness boundary.

Build the acceptance boundary in the right order

A secure handler should make five decisions before it starts the expensive work.

First, read the raw request body. Signature schemes normally cover the bytes sent over the wire. Parsing JSON and serialising it again can change whitespace, key order or escaping, which breaks verification even when the data looks identical.

Second, verify the signature with the endpoint's secret and a constant-time comparison. Reject unknown signature versions. If the provider signs a timestamp, bind that timestamp to the body and enforce a small tolerance that your clock can support. Stripe's default is five minutes, not zero. Its documentation warns that a tolerance of zero disables the recency check.

Third, validate the provider, event type and action before dispatch. A correctly signed event is not automatically relevant to every account or state transition. Match the expected tenant or installation as well as the event name.

Fourth, claim a stable event key in durable storage. Prefer the provider's event or delivery identifier. Scope it by provider and endpoint or tenant so that two integrations cannot collide. Do not derive this key from the timestamp, because legitimate retries change timestamps and separate events can share one.

Fifth, acknowledge quickly and move the business work to a queue. GitHub asks receivers to return a 2xx response within 10 seconds. Stripe likewise recommends returning success before complex logic. A short ingestion transaction reduces timeout-driven retries and gives workers their own retry policy.

Make duplicate rejection atomic

An in-memory cache is useful for load shedding, but it is not the security boundary. Two application instances can receive the same delivery at once. A restart can empty the cache. A check followed by a separate insert also leaves a race between "not seen" and "now recorded".

Use a database uniqueness constraint instead. The ingestion transaction should attempt to insert a row such as (provider, endpoint_id, event_id). If that insert conflicts, return the same successful acknowledgement you would return after accepting the first copy. Do not send an error for a known duplicate, because an error asks the provider to try again.

Store enough evidence to investigate without turning the table into a secret archive: the event identifier, type, received time, payload digest, verification result and processing state. Encrypt or omit sensitive payload fields. The raw payload may contain personal or billing data, so keeping every body forever is not a free audit feature.

The event row and the queued work must also fail together. A transactional outbox is one common pattern: insert the unique event and an outbox job in the same database transaction, then let a separate publisher move the job to the queue. Without that link, a crash can record the event as consumed before the work exists, and the next legitimate retry will be discarded as a duplicate.

Separate ingestion from business effects

Event deduplication protects the intake path. The worker still needs idempotent business logic.

Use a business key for the effect, not only the delivery key. For example, a payment event might create a fulfilment record unique on order_id and effect type. An access event might upsert the desired membership state instead of blindly adding another grant. An email job might be unique on template, recipient and triggering business object.

This second boundary covers cases where providers emit two different event objects for the same underlying change. Stripe explicitly notes that this can happen and recommends pairing the object identifier with the event type when identifying semantic duplicates.

Design handlers as state transitions where possible. "Set invoice 123 to paid if its verified payment is complete" is safer than "add one paid action every time this message arrives." If the provider API is authoritative, fetch the current object state before a high-impact action. The webhook can be the signal to reconcile, not the sole evidence for the final state.

Test retries, redelivery and disorder

The happy path proves little. Test the paths that reliable providers create on purpose.

Send the exact signed request twice inside the timestamp tolerance and confirm only one event row and one business effect. Deliver two copies concurrently to different application instances. Force the worker to fail after the event is claimed, then confirm the outbox retries the work without repeating the effect.

Request an official redelivery and confirm that your provider-specific identifier behaves as documented. For GitHub, the redelivery keeps the delivery header. For Stripe, retry signatures and timestamps change, while the event identifier remains the deduplication key.

Then reverse two related events. Stripe states that event order is not guaranteed and warns against using its second-resolution created timestamp to decide ordering or duplication. A handler that only works when created, updated and paid arrive in sequence is a delayed incident.

Finally, rotate the signing secret with an overlap period. Accept both old and new secrets only for the planned window, label which one verified the request, and remove the old secret on schedule. This tests the operational path that teams otherwise discover during an emergency.

What this does not change

Replay protection does not repair a leaked signing secret. An attacker with the active secret can create a new payload, timestamp and signature. Secret storage, narrow endpoint scope, rotation and incident revocation still matter.

It also does not authorise the requested business action. A signed event can be valid but unexpected for the tenant, account or current object state. Treat signature verification as authentication of the message, then apply normal authorisation and state rules.

The useful contract is narrow and testable: accept only authentic and recent bytes, claim each stable event identity atomically, queue the work without losing it, and make the business effect unique. A webhook signature is one part of that contract. It is not the replay boundary by itself.

Written by Dandelion Labs

Language

ENES

Search

Categories

  • All posts
  • Security6
  • Software Market2
  • AI3
  • UI/UX2
  • Engineering2
  • Open Source2
  • Company1

Share

Related posts

    Stay Updated

    Get the latest AI development insights, startup tips, and technical deep-dives delivered to your inbox. No spam, just quality content.

    Join 200+ founders and developers. Unsubscribe anytime.

    AI Insights
    Startup Tips
    Technical Guides
    Case Studies
    Dandelion Labs - Ship Fast. Scale Smarter.Dandelion Labs - Ship Fast. Scale Smarter.

    We help early-stage startups go from idea to a product built to scale.

    Company
    • About
    • Services
    • Careers
    • Blog
    • QuantaKrypto (PQC)
    Contact Us
    • [email protected]
    • Contact Us

    Copyright © 2021-2026 | Dandelion Labs JSC

    Privacy PolicyTerms & Conditions