Back to Blog
securitywebhooksauthenticationreplay-attackshmac

Preventing Webhook Replay Attacks: Beyond Timestamp Windows

Signature verification proves a webhook was sent by a trusted party. It does not prove it was sent right now. Here's how replay attacks work, why timestamp windows are necessary but not sufficient, and how to build durable protection with nonce caching.

N
Nadia Kowalski
Security Engineer
April 24, 2026
9 min read

Webhook signature verification is standard practice at this point. You compute an HMAC over the request body, compare it to the signature in the X-Webhook-Signature header, and reject anything that doesn't match. If you're doing this, you've closed the most obvious attack vector: an attacker crafting a fake payload from scratch.

What signature verification does not protect against is a replay attack. An attacker intercepts a legitimate signed request — one your system would accept — and sends it again. The signature is valid. Your verification logic passes. The event fires a second time.

Depending on what the webhook triggers, a replay is anywhere from mildly annoying (a duplicate notification) to a serious security incident: a second money transfer, a second account privilege escalation, a second order fulfillment on a refunded purchase.

This post covers how replay attacks work in the webhook context, the limits of timestamp-based defenses, and how to implement nonce-based replay protection that closes the gap.


Why Signature Verification Isn't Enough

A Stripe-compatible webhook signature looks like this:

X-Webhook-Signature: t=1745431200,v1=a3f9b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1

The t= value is a Unix timestamp. The v1= value is HMAC-SHA256(secret, "<timestamp>.<raw body>"). Including the timestamp in the signed material means you cannot produce a valid v1 signature for a body without also controlling the timestamp in the header.

This prevents an attacker from replaying a request with a modified timestamp. But it does nothing to prevent replaying a request with the original timestamp. As long as your consumer accepts the original t= value, the replay succeeds.


The Timestamp Window Defense

The standard mitigation is to reject events whose timestamp is older than a fixed window — typically five minutes:

go
func verifyTimestamp(sigHeader string, tolerance time.Duration) error {
    parts := parseSignatureHeader(sigHeader)
    ts, err := strconv.ParseInt(parts["t"], 10, 64)
    if err != nil {
        return fmt.Errorf("missing or invalid timestamp in signature header")
    }
    age := time.Since(time.Unix(ts, 0))
    if age > tolerance || age < -30*time.Second {
        return fmt.Errorf("signature timestamp out of acceptable range: age=%v", age)
    }
    return nil
}

The negative check (age < -30*time.Second) handles clock skew: a request that appears to arrive 30 seconds in the future is likely a clock drift issue, not an attack.

This is a meaningful improvement. An attacker who intercepts a webhook at 12:00:00 can only replay it before 12:05:00. That is a five-minute window rather than an indefinite one.

The problem: five minutes is long enough. Payment fraud automation operates in seconds. An attacker with network-level access — an insider, a compromised reverse proxy, or a MITM on a misconfigured TLS endpoint — can replay a request within that window with ease.


What the Timestamp Window Doesn't Cover

ScenarioAttack windowTimestamp defense adequate?
Automated fraud (bots)<1 secondNo
Compromised reverse proxySeconds to minutesMarginal
Insider threat with network accessMinutesMarginal
Unencrypted HTTP endpointIndefiniteNo
Valid webhook retried by providerUp to 24 hoursN/A — this is legitimate

The last row is important: legitimate providers retry failed webhooks for hours. If your window is too tight, you reject valid retries. If it is too loose, replay protection weakens. Five minutes is the conventional compromise — but it is only a first layer.


Nonce-Based Replay Prevention

A nonce (number used once) is a unique identifier included in the webhook that, once seen, is recorded so future requests presenting the same value are rejected. Combined with a timestamp window, this makes replay attacks infeasible even for attackers operating inside the window.

The sender includes a unique delivery ID in each request:

X-Webhook-Delivery-Id: whd_01HWXYZ9A8B7C6D5E4F3G2H1J0
X-Webhook-Signature: t=1745431200,v1=a3f9b2...

The consumer's verification logic:

go
func verifyAndConsumeNonce(
    ctx context.Context,
    deliveryID string,
    sigHeader string,
    payload []byte,
    secret []byte,
    cache NonceCache,
) error {
    // Step 1: Reject if timestamp is outside the window
    if err := verifyTimestamp(sigHeader, 5*time.Minute); err != nil {
        return err
    }

    // Step 2: Reject if this delivery ID has been seen before
    seen, err := cache.Exists(ctx, deliveryID)
    if err != nil {
        return fmt.Errorf("nonce cache unavailable: %w", err)
    }
    if seen {
        return fmt.Errorf("duplicate delivery ID rejected: %s", deliveryID)
    }

    // Step 3: Verify the HMAC signature
    if err := verifySignature(payload, sigHeader, secret); err != nil {
        return err
    }

    // Step 4: Record the nonce — slightly longer TTL than the window
    ttl := 10 * time.Minute
    if err := cache.Set(ctx, deliveryID, ttl); err != nil {
        log.Printf("warn: failed to record nonce %s: %v", deliveryID, err)
    }

    return nil
}

The order matters: check the timestamp before hitting the cache, and check the cache before verifying the HMAC. Fail fast on the cheapest checks first.


Cache Implementation Options

Your nonce cache needs two operations: Exists(id) and Set(id, ttl). The TTL should be at least as long as your timestamp window, with a small buffer for clock skew.

Redis is the typical choice. SET key 1 EX <seconds> NX is atomic and performs the existence check and insert in a single round trip.

Postgres works fine at lower volumes:

sql
CREATE TABLE webhook_nonces (
    delivery_id TEXT        PRIMARY KEY,
    expires_at  TIMESTAMPTZ NOT NULL
);

CREATE INDEX idx_webhook_nonces_expires ON webhook_nonces (expires_at);

Insert with ON CONFLICT (delivery_id) DO NOTHING so concurrent requests for the same ID are safe. A background job pruning WHERE expires_at < NOW() keeps the table lean. At a few hundred events per second this holds up; above that, Redis is the right call.


Fail Open vs. Fail Closed

The hardest design decision is what to do when the cache is unavailable.

Fail closed (reject all requests when the cache is down) gives strong security guarantees but creates a reliability dependency. If your Redis instance has an outage, your webhook consumer stops accepting events.

Fail open (accept the event and log a warning) preserves availability but creates a window where replays could succeed during the outage.

There is no universally correct answer. For a consumer executing payment captures or money movements, fail closed is the right choice. For a consumer refreshing a dashboard widget, fail open is fine. Document the decision explicitly — an undocumented // TODO: handle this better comment in this code path is a time bomb.


Provider Support for Delivery IDs

Not all providers include a stable delivery ID. Here is what the major ones offer:

ProviderUnique delivery IDTimestamp in signatureRecommended approach
StripeStripe-Idempotency-KeyYes (t= in Stripe-Signature)Timestamp window + nonce
GitHubX-GitHub-Delivery (UUID)NoNonce only, long TTL
ShopifyX-Shopify-Webhook-IdNoNonce only, long TTL
TwilioNoneNoDerive from body hash
SendGridX-Twilio-Email-Event-Webhook-IdNoNonce only, long TTL

For providers without a delivery ID (Twilio is the awkward case), derive one from the payload: SHA256(provider_name + ":" + body). This is deterministic — the same event hashes to the same value — so replay protection works, but legitimately retried webhooks with identical bodies will also be blocked. For Twilio's typical use cases this trade-off is acceptable.

For providers without timestamp support, set a longer nonce TTL (24–48 hours) to cover the provider's full retry window.


Putting It Together

Replay attack prevention is a two-layer control:

  1. Timestamp window — limits the attack window to minutes
  2. Nonce cache — eliminates replays within that window entirely

Neither is sufficient alone. The timestamp window has no effect against replays within the window; the nonce cache only protects against delivery IDs it has seen, which gives no protection if the cache is cold after a restart.

Together, they are practical and strong. An attacker must replay a webhook before the timestamp expires and must deliver it before your consumer has seen that delivery ID. In practice this is infeasible except for an attacker with simultaneous intercept-and-discard access to your stream — which is a problem for TLS to solve, not application-layer controls.

GetHook includes a unique delivery ID on every forwarded webhook, giving your consumer a stable value to use as a nonce without hashing the payload or deriving your own identifier.

If you are building or hardening a webhook consumer, start with timestamp validation, add a nonce cache, and document your fail-open vs. fail-closed policy. The implementation is a few hours of work. The alternative — discovering you have been processing replayed payment events — is a much longer conversation.

Start building with GetHook →

Stop losing webhook events.

GetHook gives you reliable delivery, automatic retry, and full observability — in minutes.