A webhook signature proves the request was produced by someone holding the shared secret. It says nothing about when. Anyone who captures a correctly signed request can resend it verbatim, and your verification code will pass it every time. Replay protection is the missing half: sign a timestamp along with the payload, reject anything outside a short tolerance window, and dedupe on the delivery id.
Signature verification is usually where webhook security stops. You compute an HMAC over the raw body, compare it to the header, and if it matches you trust the request. That check is necessary and not sufficient, because a signature is a statement about authorship, not about freshness. Nothing in the math expires.
Closing that gap takes two more controls: a signed timestamp with a tolerance window, and a record of delivery ids you have already seen. Both sit alongside the verification step covered in the full webhook security model.
How a replay actually happens
A replay attacker never needs your secret. They need one copy of a request you already accepted, and signed bodies leak in ordinary ways. An application logs full request bodies and headers at debug level, and the log ships to a third-party aggregator. A proxy stores payloads for inspection. A CI job dumps a captured fixture into a public build artifact. An intermediary in the path is compromised and collects traffic for a while.
Any of those hand over a complete, valid, signed request. The attacker replays it, your HMAC check passes because it is the same bytes and the same secret, and your handler runs.
Whether that matters depends on what the handler does. A webhook that refreshes a cache is boring to replay. One that credits an account balance, issues a license key, or triggers a shipment is worth replaying five hundred times. The attacker cannot forge a new event, but they can multiply an old one, and for a lot of business logic that is just as good.
Sign the timestamp, don’t just send it
The obvious fix is to include a timestamp and reject old requests. The subtle part is where the timestamp lives.
If the provider sends the time in a separate, unsigned header, the check is theatre. An attacker replaying a captured request rewrites that header to the current time. The body is unchanged, the signature still matches the body, and your freshness check passes with an attacker-chosen value. An unsigned timestamp is not evidence of anything.
The timestamp has to be part of the signed material. Stripe’s scheme is the canonical example: the Stripe-Signature header carries a t= timestamp and one or more v1= signatures, and the HMAC is computed over the timestamp concatenated with the raw body rather than the body alone. Changing t changes the signed string, so the signature stops verifying. That is what makes the timestamp trustworthy — the same secret binds it to the payload. We walk through the exact string construction in how Stripe’s t= timestamp is signed alongside the payload, and Stripe documents the scheme in its webhook signature reference.
Two rules follow. Verify over the exact raw bytes you received, before any JSON parsing. And treat the timestamp as untrusted until the signature check passes, then as authoritative.
Choosing a tolerance window
Once the timestamp is trustworthy, replay protection is one comparison: reject the request if abs(now - t) exceeds some tolerance. That tolerance is a real trade-off, not a constant to copy.
A short window shrinks the interval in which a captured request stays useful. It also rejects deliveries that were legitimately delayed: a provider retry after an outage, a request that spent forty seconds in a saturated load balancer. The provider sees a failure, retries harder or gives up, and you have converted a security control into availability loss.
Table: how the tolerance window trades replay exposure against the risk of rejecting slow but legitimate deliveries.
| Tolerance | Window an attacker can reuse a captured request | Risk to legitimate traffic |
|---|---|---|
| 30 seconds | Very narrow | High — normal retries and queueing delays get rejected |
| 5 minutes | Narrow; this is Stripe’s documented default tolerance | Low for direct provider-to-endpoint delivery |
| 1 hour | An hour of usable capture per request | Very low |
| None | Indefinite — the request is valid forever | None |
Five minutes is a reasonable starting point because it exceeds normal network and retry jitter without leaving much room to work with. Tighten it if your handler does something irreversible and your delivery path is short. Loosen it if you sit behind a queue that can legitimately hold a request longer, and compensate with dedupe.
The window bounds exposure without eliminating it. Inside the tolerance a captured request still replays fine, which is what the delivery-id check below is for.
Clock skew is the thing that breaks this in production
A tolerance check compares the provider’s clock to yours, so drift silently changes the window. A server running two minutes fast against a five-minute tolerance has cut its acceptance window to three minutes on one side and stretched it to seven on the other. Drift far enough and you reject every event a provider sends, which presents as a total webhook outage with no obvious cause: signatures verify, payloads look fine, everything is stale.
Keep NTP running and monitored on every host that performs the check, including containers inheriting a host clock and any machine that has been suspended or migrated. Alert on drift instead of discovering it during an incident.
Log enough on rejection to tell the two failure modes apart: the signed timestamp, your current time, the computed delta, and the delivery id, never the secret or the signature. A consistent delta across every rejection is a clock problem. A scatter of deltas from one source address is an attack. Without the delta, both look identical.
When the provider doesn’t sign a timestamp
Plenty of providers do not sign a timestamp. GitHub signs the payload with HMAC-SHA256 in X-Hub-Signature-256 but includes no signed time, so a tolerance check has nothing trustworthy to read. What it does give you is X-GitHub-Delivery, a unique identifier per delivery, listed with the other webhook delivery headers.
Your defense there is a seen-id store. After the signature verifies, check whether you have already processed that delivery id; if so, drop the request and return success. If not, record it and continue. That is a nonce store, and its job is to guarantee a given signed request is acted on exactly once.
Two details matter. Write the id before you do the work, in the same transaction if you can, or two concurrent replays race past each other. And keep ids for at least the provider’s full retry schedule: if a provider retries for three days, a one-hour retention window leaves you unprotected on day two.
Shopify sits in the same category: a base64 HMAC over the raw body, no signed timestamp, so the same seen-id discipline applies there. This store is not your application’s idempotency table, even though both prevent double processing. The nonce store is a security control keyed on the transport-level delivery id and sized to the retry window. Application idempotency is keyed on the business event id and lives much longer, because a provider can legitimately send the same event under two deliveries. You want both; the second is covered in dedupe with a stable event id.
Replay the attack vs replay the feature
The word “replay” does double duty in webhook systems, and the two meanings are opposites. One is an unauthenticated third party resending a captured request. The other is an operator deliberately reprocessing an event that failed delivery: the one-click or bulk resend from a dead-letter queue after you fix the bug that was rejecting it.
Table: the two things called “replay” differ in who initiates them, what they prove, and what defends against them.
| Replay attack | Replay from a dead-letter queue | |
|---|---|---|
| Initiated by | Anyone holding a captured signed request | An authenticated operator in your workspace |
| Source of the payload | A copy leaked from a log, proxy or intermediary | The event your gateway already verified and stored |
| Visibility | None — it looks like ordinary inbound traffic | An explicit action, recorded in per-attempt history |
| Timing | Attacker’s choice, unbounded without a tolerance check | Deliberate, after the underlying failure is fixed |
| Correct response | Reject via tolerance window plus delivery-id dedupe | Let it through; your consumer’s idempotency absorbs it |
The distinction is about where the trust boundary sits. Replay protection guards the boundary between the outside world and your gateway. Replay from the dead-letter queue happens inside that boundary, on payloads verified when they first arrived. Blocking it would mean throwing away failed deliveries, which is the opposite of what you want, and it is also why the two controls do not collide. In Webhooker the checks apply at ingest, before a payload is accepted; an operator replay reuses an event that already passed them and is recorded as a deliberate action with its full attempt history, not a silent repeat. If you would rather not build the ingest-side half yourself, signature verification with replay protection at ingest is part of the gateway, and you can verify signatures and timestamps at the edge on the free plan.
One last thing: do not answer a stubborn rejection problem by turning verification off “temporarily”. An endpoint that accepts unverified payloads is a worse failure than the one you were debugging.
Frequently asked questions
How long should the tolerance window be?
Long enough to absorb normal delivery delay, short enough that a captured request stops being useful quickly. Five minutes is a sensible default and matches Stripe’s documented tolerance. Go tighter when your handler does something irreversible and the delivery path is short. Go wider when a queue or proxy can legitimately hold a request longer, and pair that with delivery-id dedupe so a captured request still cannot be processed twice. Whatever you pick, monitor clock drift: skew quietly changes the window you think you configured.
Is a nonce better than a timestamp window?
They solve different halves of the problem, so use both. A tolerance window bounds how long a captured request stays valid, but does nothing about a replay arriving inside the window. A nonce store, recording each delivery id and refusing repeats, blocks that. On its own, though, a nonce store has to retain ids forever, because without a trusted timestamp there is no point at which an old request becomes safe to forget. Together, the window bounds retention and the nonce closes the remaining gap.
Does HTTPS prevent replay attacks?
No. TLS protects the request in transit, so an observer on the network cannot read or modify it. Replays rarely come from the wire. They come from places where the request has already been decrypted and stored: application logs, proxy inspection buffers, captured test fixtures, a compromised intermediary that terminates TLS. A signed request pulled out of a log file replays over a perfectly valid HTTPS connection. TLS is required, and it is not a replay defense.