Back to Blog
Security

Webhook Replay Attacks: Why Signature Verification Alone Won't Save You

HMAC signatures stop forgery, but replay attacks bypass them entirely. Timestamp validation, nonce tracking, and idempotent handlers are what actually protect you.

WebhookVault Team··7 min read

You Verified the Signature. Great. Now What?

Most webhook security guides end at HMAC verification. Validate the signature, reject anything that doesn't match, done. And look, that's necessary. But it's not sufficient.

A valid signature proves the payload came from the right sender. It says nothing about when it was sent. An attacker who intercepts a legitimate webhook, signature and all, can replay it hours, days, or weeks later. The signature still checks out. Your handler processes it again.

Double charges. Duplicate account creations. Inventory going negative because the same order.completed event hit your system fourteen times.

How Replay Attacks Actually Work

The mechanics are embarrassingly simple. An attacker captures a valid webhook request, including headers. Maybe they compromised a log aggregator that was storing full request bodies (you'd be surprised how often this happens). Maybe there's a misconfigured reverse proxy dumping traffic to an unsecured endpoint. Or maybe they're sitting on the same network, sniffing unencrypted internal traffic between your load balancer and app servers.

They don't need to forge anything. They just... send it again.

# Captured from a log file, signature included
curl -X POST https://api.yourapp.com/webhooks/stripe \
  -H "Stripe-Signature: t=1679529600,v1=5257a869..." \
  -H "Content-Type: application/json" \
  -d '{"type":"checkout.session.completed","data":{"object":{"id":"cs_live_abc123","amount_total":9900}}}'

# Same request, sent 3 weeks later
# Your HMAC check passes. The payment processes again.

Stripe, GitHub, Shopify, they all include timestamps in their signature schemes for exactly this reason. But plenty of smaller providers don't. And even when timestamps are available, a shocking number of implementations just... ignore them.

Timestamp Validation Is Your First Line of Defense

The fix is straightforward. Every webhook provider worth its salt includes a timestamp, either in the signature header or the payload body. You compare that timestamp against your server's current time. If the difference exceeds a tolerance window, reject the request.

Five minutes is standard. Some teams go tighter, some looser. Stripe uses a 5-minute default. Going below 2 minutes gets risky because clock drift between servers is a real thing, and you'll start rejecting legitimate webhooks.

function validateTimestamp(signatureHeader: string, toleranceSeconds = 300): boolean {
  // Stripe format: t=1679529600,v1=abc123...
  const timestamp = parseInt(
    signatureHeader.split(',')[0].replace('t=', ''),
    10
  )

  const now = Math.floor(Date.now() / 1000)
  const age = Math.abs(now - timestamp)

  if (age > toleranceSeconds) {
    console.warn(
      // stale webhook, could be replay or just slow delivery
      `Webhook timestamp too old: ${age}s (tolerance: ${toleranceSeconds}s)`
    )
    return false
  }

  return true
}

Quick note on that Math.abs call. You want absolute difference, not just checking if the timestamp is in the past. A webhook with a timestamp 10 minutes in the future is equally suspicious; it means either someone is messing with the payload or there's a serious clock sync issue on the sender's side.

When Timestamps Aren't Enough

Timestamps close the window but don't eliminate it. Within that 5-minute tolerance, replays still work. For high-value operations (payments, account deletions, permission changes), you need something stronger.

Nonce tracking.

A nonce is a unique identifier for each webhook delivery attempt. Most providers include one, though they call it different things. Stripe uses the event ID. GitHub puts a delivery GUID in X-GitHub-Delivery. Shopify has X-Shopify-Webhook-Id.

You store every nonce you've seen, and reject duplicates. The implementation looks deceptively simple:

import Redis from 'ioredis'

const redis = new Redis(process.env.REDIS_URL)

async function isReplay(webhookId: string, ttlSeconds = 86400): Promise<boolean> {
  const key = `webhook:nonce:${webhookId}`

  // SET NX returns null if key already exists
  const result = await redis.set(key, '1', 'EX', ttlSeconds, 'NX')

  if (result === null) {
    // we've seen this before
    console.warn(`Duplicate webhook detected: ${webhookId}`)
    return true
  }

  return false
}

// In your handler
app.post('/webhooks/stripe', async (req, res) => {
  const eventId = req.body.id  // evt_1234abc...

  if (await isReplay(eventId)) {
    // 200 so they don't retry, but don't process
    return res.status(200).json({ received: true, duplicate: true })
  }

  // Actually process the event
  await handleEvent(req.body)
  res.status(200).json({ received: true })
})

Two things people get wrong here. First, the TTL. You need to keep nonces long enough to cover your timestamp tolerance plus some buffer. If you allow 5-minute-old webhooks but only store nonces for 60 seconds, there's a gap an attacker can exploit. 24 hours is safe for most use cases. Keep it in Redis or some other fast store; checking a database table for every webhook adds latency you don't want.

Second, always return 200 for duplicates. If you return 409 or 422, the provider might interpret that as a failure and retry, creating a weird loop where legitimate retries get flagged as replays.

The Provider Doesn't Include a Nonce. Now What?

Some providers, especially internal services or smaller SaaS platforms, don't include any unique delivery identifier. You're stuck generating your own.

Hash the entire payload plus the timestamp. Not cryptographically perfect, but practical:

import crypto from 'crypto'

function generateNonce(body: string, timestamp: string): string {
  return crypto
    .createHash('sha256')
    .update(`${timestamp}:${body}`)
    .digest('hex')
}

// Same dedup logic as before, just with a derived nonce
const nonce = generateNonce(JSON.stringify(req.body), req.headers['x-webhook-timestamp'])
if (await isReplay(nonce)) {
  return res.status(200).json({ received: true })
}

This has a subtle edge case. If the provider sends the exact same payload with the exact same timestamp (which happens with some retry mechanisms), your derived nonce will match and you'll reject a legitimate retry. The tradeoff is usually acceptable for high-security endpoints. For lower-risk webhooks, idempotent handlers are a better bet anyway.

Idempotent Handlers Are the Safety Net

All of this nonce and timestamp checking is perimeter defense. Your handlers should also be idempotent, meaning processing the same webhook twice produces the same result as processing it once. Because webhooks will get delivered multiple times, replay attack or not. Providers retry on timeouts. Networks glitch. Load balancers send duplicate requests. It just happens.

// Bad: charges the customer every time
async function handlePaymentWebhook(event: PaymentEvent) {
  await stripe.charges.create({
    amount: event.amount,
    customer: event.customerId,
  })
}

// Good: checks if already processed
async function handlePaymentWebhook(event: PaymentEvent) {
  const existing = await db.query(
    'SELECT id FROM processed_events WHERE event_id = $1',
    [event.id]
  )

  if (existing.rows.length > 0) {
    // already handled, skip
    return
  }

  await db.transaction(async (tx) => {
    await tx.query(
      'INSERT INTO processed_events (event_id, processed_at) VALUES ($1, NOW())',
      [event.id]
    )
    await tx.query(
      'UPDATE orders SET status = $1 WHERE stripe_session_id = $2',
      ['paid', event.sessionId]
    )
  })
}

Wrapping the idempotency check and the business logic in a single transaction is crucial. Without it, there's a race condition where two concurrent deliveries both pass the check before either inserts the record. Use a unique constraint on event_id and catch the conflict.

Putting It All Together

Defense in depth. Not one mechanism, all of them layered:

  1. TLS everywhere. Obvious, but I've seen internal services sending webhooks over plain HTTP between Kubernetes pods because "it's internal." Until someone compromises a pod and sniffs traffic.
  2. HMAC signature verification. Stops forged requests.
  3. Timestamp validation. Closes the replay window to minutes instead of forever.
  4. Nonce deduplication. Catches replays within the timestamp window.
  5. Idempotent handlers. Makes double-processing harmless even when everything else fails.

Skip any one of these and you've got a gap. A fintech startup I worked with had perfect HMAC verification but no timestamp checking. Someone replayed a refund webhook from two months ago and it processed again, because the signature was still valid. That was a fun incident postmortem.

Your webhook endpoints are publicly reachable URLs that trigger business logic. Treat them like you'd treat any authentication endpoint. Because that's basically what they are.