CanHook
Features Pricing Docs FAQ Blog Log in Get started free

Preventing Webhook Replay Attacks With a Timestamp Window

By The CanHook Team · September 3, 2026 · 1156 words
In short

A webhook replay window is the maximum age a request's timestamp can have before your handler rejects it, stopping a captured, correctly-signed request from being processed twice. Five minutes is the common default, matching Stripe and Slack, and a short-lived record of processed signatures closes the small gap the window leaves open.

A stolen webhook payload does not need to be modified to cause damage. If an attacker, or a broken retry loop, captures a valid, correctly-signed request and resends it later, a naive signature check waves it straight through — a payment gets marked paid twice, an order ships again, a deleted account gets re-provisioned. A replay window is the maximum age a webhook's timestamp can have before your handler rejects it outright, and it is the piece most signature-verification guides skip. CanHook is a webhook inspector that captures every inbound request exactly as a provider sent it, headers included, which is a useful way to see real timestamp values before you decide how wide to set your own window.

What Is a Webhook Replay Attack?

A replay attack is any case where a request your server already processed once gets sent again, and your handler processes it a second time as if it were new. The signature is still valid, because nothing about the payload or the HMAC changed — only the fact that you are seeing it twice. The attack does not require breaking the signature scheme at all; it only requires access to one previously-sent, correctly-signed request, which can leak through a browser history, a logging pipeline, a proxy, or a compromised endpoint URL.

This is different from a forged webhook, where the attacker does not have a valid signature and has to guess or steal the signing secret. Replay protection and signature verification solve two separate problems, and a handler needs both.

How Does a Timestamp Window Stop It?

A timestamp window stops it by rejecting any request whose signed timestamp is older than a fixed number of seconds, so a replayed request fails before your handler ever acts on it. Most providers sign a string that includes that timestamp, not just the payload body. Your handler recomputes the HMAC over the same signed string, compares it to the header in constant time, and separately checks that the timestamp is recent enough to trust.

A minimal Node.js check looks like this:

const crypto = require('crypto');

function isValidWebhook(rawBody, signatureHeader, timestampHeader, secret, toleranceSeconds = 300) {
  const age = Math.abs(Date.now() / 1000 - Number(timestampHeader));
  if (age > toleranceSeconds) {
    return false; // outside the replay window, reject before touching HMAC
  }

  const signedPayload = `${timestampHeader}.${rawBody}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signatureHeader)
  );
}

The same logic, as discrete steps:

  1. Extract the timestamp value from the provider's signature header, not from the request body.
  2. Compute the absolute difference between that timestamp and your server's current time.
  3. Reject the request immediately if the difference exceeds your tolerance window, before computing any HMAC.
  4. Only then recompute the signature over the same signed string the provider used, and compare it in constant time.
  5. Log rejected timestamps separately from failed signatures, so clock drift and forged requests show up as different problems.

How Wide Should Your Replay Window Be?

Wide enough to absorb real clock drift and network latency, narrow enough that a leaked request stops being useful quickly. Five minutes is the number most major providers converge on, and it is a reasonable default if you are choosing your own for an internal or outbound webhook system.

ProviderTimestamp headerTolerance window
Stripet= inside Stripe-Signature5 minutes (default, configurable)
SlackX-Slack-Request-Timestamp5 minutes
GitHubnonenot enforced — signature only

The Stripe webhook docs specify a default five-minute tolerance on the t= value inside the Stripe-Signature header, and Slack's request-verification guide requires X-Slack-Request-Timestamp to be within five minutes of your server's clock. GitHub's webhook signature has no timestamp component at all, so replay protection there is entirely your own responsibility — the X-GitHub-Delivery header gives you a unique ID per delivery attempt, useful for deduplication but not itself a freshness check. See our guide to verifying Stripe webhook signatures for the full HMAC comparison.

Do You Also Need a Nonce Store?

Yes, if the action behind the webhook is expensive or harmful to repeat — a timestamp window alone only narrows the replay opportunity down to minutes, it does not close it entirely. Anyone who captures a request within that window can still replay it once before it ages out. If the action is charging a card, sending an SMS, or provisioning a resource, pair the timestamp check with a short-lived store of signatures or delivery IDs you have already processed, keyed with a TTL slightly longer than your tolerance window. A Redis SETNX with a 10-minute expiry is enough for most teams; you do not need a database table for this.

Testing Your Tolerance Logic Against Real Requests

The hard part of this code is rarely the HMAC comparison — it is confirming your age check behaves correctly against a real provider's actual header format and clock, not a payload you typed by hand. Point a provider's webhook configuration at a CanHook catch URL first, send a real test event, and open the captured request to see the exact timestamp header, its format, and how far it drifted from your own server's clock by the time it arrived. That is the input your tolerance function needs to be tested against, not a guess.

Once you can see the real header, replay the exact captured request against your own handler with CanHook's replay feature and confirm your window rejects it once its timestamp has aged out — deliberately wait past your tolerance value, then replay the same capture again. See how request capture works before you wire this into a provider's live webhook settings.

Common Replay-Window Mistakes

Comparing timestamps in local time instead of UTC is the most common bug — a server with the wrong timezone rejects every legitimate request, and nobody notices until support tickets show up. Trusting a client-supplied timestamp that is not itself part of the signed string is the second: if the timestamp is not covered by the HMAC, an attacker can update it and skip your check entirely. And setting the tolerance to something extreme, like 24 hours, to make integration testing easier and then shipping that value to production removes the protection you added it for in the first place.

Is This Worth Building for a Low-Traffic Endpoint?

If the webhook triggers something reversible and cheap, like a Slack notification or a log line, a replay is mostly harmless, and you can reasonably skip the extra code. If it triggers something that costs money, sends a message to a customer, or changes account state, the check is about fifteen lines and pays for itself the first time a webhook URL ends up somewhere it should not. Start with the timestamp window; add the nonce store only once you have a specific action that cannot tolerate running twice.

Create a free CanHook endpoint, point one real webhook at it, and inspect the timestamp header it actually sends — that alone tells you whether your tolerance window needs to be five minutes or something wider. Sign up free and get a catch URL in under a minute.

Frequently asked questions

What is a webhook replay attack?

A webhook replay attack is when a previously captured, correctly-signed webhook request gets resent and processed again as if it were new. The signature still passes because nothing about the payload changed — only the fact that your handler is seeing it a second time. A timestamp window and a short-lived record of processed signatures are what stop it, not a stronger HMAC secret.

How wide should a webhook replay window be?

Five minutes is a reasonable default and matches what Stripe and Slack both use for their own webhook signatures. Narrower windows reduce how long a leaked request stays useful but risk rejecting legitimate requests during real clock drift or network delay. Start at five minutes and only widen it if you see false rejections in your logs.

Does GitHub enforce a webhook replay window?

No. GitHub's webhook signature has no timestamp component, so there is no built-in replay protection, only proof that the payload matches your signing secret. If replay protection matters for your integration, add your own check using the X-GitHub-Delivery header alongside your own timestamp or nonce logic.

Should I check the timestamp before or after verifying the HMAC signature?

Check the timestamp first. Rejecting an expired request before computing the HMAC avoids wasted work on a request you are discarding anyway, and it keeps the two checks independent, so a bug in one does not mask a failure in the other during testing.

Is a replay window enough on its own, or do I need a nonce store too?

A timestamp window narrows the opportunity to replay a request down to minutes, but does not close it entirely — a request can still be replayed once before it ages out. Pair it with a short-lived store of processed signatures or delivery IDs if the webhook triggers something expensive or harmful to run twice, like charging a card.

How can I see the real timestamp header a provider sends before writing my tolerance check?

Point the provider's webhook configuration at a catch URL. CanHook's free tier captures the raw headers, body, and content-type of any inbound request, so you can inspect the actual timestamp value and format instead of guessing what your tolerance function needs to handle.