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 the absolute difference, in both directions. 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 one transaction is the part people skip. 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.
Layers That Cover Each Other
Defense in depth, with every layer earning its place. TLS everywhere sounds too obvious to mention until you find internal services posting webhooks over plain HTTP between Kubernetes pods because "it's internal", which holds right up to the moment someone compromises a pod and starts reading traffic. HMAC verification stops forged requests. Timestamp validation shrinks the replay window from forever down to minutes. Nonce deduplication catches the replays that land inside that window. And idempotent handlers make a double-processed event harmless on the day everything above them 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.
Frequently asked questions
If the signature checks out, can the request still be hostile?
Yes. A signature proves the payload was produced by the holder of the secret at some point; it says nothing about when, or how many times it has been sent. Anything that can capture a valid request, from a logging proxy to a compromised intermediary, can send it again unchanged and your verification will pass every time.
How wide should the timestamp tolerance be?
Minutes, not hours. The window has to absorb clock skew between the sender, any intermediary and your server, plus normal delivery delay, and beyond that every extra minute is time an attacker can replay in. Five minutes is the common choice; whatever you pick, reject anything outside it rather than logging and continuing.
What if the provider sends no unique delivery identifier?
Derive one. The signature itself, or a hash of the signature and timestamp together, is unique per delivery and can be stored as a nonce. Keep those entries for at least the length of your timestamp tolerance and reject anything you have already seen, which closes the replay window that the timestamp check leaves open.
With timestamps and nonces in place, do handlers still need to be idempotent?
Yes. Timestamps and nonces are perimeter defence, and perimeter defence fails: a nonce store gets flushed, a deployment resets in-memory state, a tolerance window is widened during an incident and never narrowed. Idempotent handlers make a duplicate harmless even when everything in front of them has been bypassed, which is the only layer that keeps working when the others do not.