Protecting Webhook Endpoints from Replay Attacks (And Why Timestamps Aren't Enough)
Protecting Webhook Endpoints from Replay Attacks (And Why Timestamps Aren't Enough) Last updated: September 21, 2026 Webhook signatures and timestamps stop forged and stale...

Protecting Webhook Endpoints from Replay Attacks (And Why Timestamps Aren't Enough)
Last updated: September 21, 2026
Webhook signatures and timestamps stop forged and stale requests, but neither stops someone from resending a genuine, freshly signed request many times inside the tolerance window. This guide explains why, shows how the major providers handle it, and walks through a tested Node.js + Redis implementation that guarantees single-use processing.
Table of Contents
- The Hidden Gap in Webhook Verification
- What Is a Webhook Replay Attack?
- Why HMAC Signatures Still Pass on a Replay
- What Real Providers Sign (and Don't)
- Why Timestamps Aren't Enough
- Two Different Windows: Replays vs. Provider Retries
- Architecture: A Distributed Webhook Nonce Cache
- Production Code: Express.js + Redis
- Testing Your Replay Protection
- Adapting the Pattern to Stripe, GitHub and Shopify
- Edge Cases and Resilience
- Webhook Hardening Checklist
- References
1. The Hidden Gap in Webhook Verification
Webhooks are how modern systems talk to each other in real time: Stripe tells you a payment succeeded, GitHub tells you a push happened, Shopify tells you an order was created. Because a webhook endpoint is just a public HTTP URL, anyone can send it a request, so every consumer has to verify that a request is genuine.
The standard recipe has two parts:
- HMAC signature verification proves the payload came from someone holding the shared secret and wasn't modified.
- Timestamp verification proves the request is recent, typically within a five-minute tolerance, so an old capture can't be replayed next week.
Together these stop forged messages, tampered payloads, and stale replays. They do not stop this:
An attacker (or a buggy proxy) obtains one valid signed request and sends it 500 times within the next few minutes.
Every copy carries a correct signature and a fresh-enough timestamp. Unless your application deduplicates, each copy triggers its side effect: a duplicate credit, a second shipment, an extra refund, another provisioned resource.
The fix is a nonce cache: remember the unique ID of every webhook you've accepted and reject any ID you've seen before. This article explains why that layer is needed, how to build it correctly with Redis, and where the popular "just cache the ID for five minutes" advice falls short.
2. What Is a Webhook Replay Attack?
A replay attack happens when someone captures a valid webhook request and resends it to your endpoint without modifying it.
+------------------+ captured request +-------------------+
| Webhook Provider | ------------------------------> | Attacker / Bot |
+------------------+ (logs, proxies, debug tools) +-------------------+
| |
| original delivery | repeated deliveries
v v
+--------------------------------------------------------------------------+
| Your Webhook Endpoint |
| 1. Signature valid [PASS] |
| 2. Timestamp in window [PASS] |
| 3. Business logic [RUNS ONCE PER COPY] |
+--------------------------------------------------------------------------+
Unlike a classic man-in-the-middle attack, nothing is decrypted or altered. The attacker doesn't need your secret, and doesn't need to understand the payload. The replayed request is byte-for-byte identical to the original, so it passes every cryptographic check.
How do attackers get a valid request?
If your endpoint uses HTTPS, on-path eavesdropping is not the realistic route. (The Standard Webhooks specification points out that signatures provide authenticity, not confidentiality, which is why HTTPS is still required.) Captured requests usually leak from the edges of your own infrastructure:
- Logs and observability tools that record raw headers and bodies: log aggregators, error trackers, APM tools, API gateway access logs.
- Reverse proxies, gateways, and WAFs that archive request bodies for debugging or inspection.
- Staging and development environments that receive copies of production webhook traffic with weaker access controls.
- Request inspectors and tunnels used during development, plus payloads pasted into tickets and chat threads.
- Compromised CI jobs or developer machines that hold recorded requests.
Hooklistener's Stripe webhook security guide describes the same pattern: a logging sidecar archives raw payloads, and someone later re-sends one to double-credit an account.
Not every replay is malicious, either. Providers deliberately retry deliveries, and networks occasionally duplicate requests. Stripe's documentation states plainly that an endpoint may receive the same event more than once. A nonce cache protects you from both hostile and accidental duplicates.
3. Why HMAC Signatures Still Pass on a Replay
To see why a valid signature can't prevent replays, look at how signing works. In the Standard Webhooks scheme (an open specification whose 1.0.0 version defines the headers webhook-id, webhook-timestamp, and webhook-signature), the provider builds a string from three parts joined by full stops, and signs it with HMAC-SHA256:
signed_content = msg_id + "." + timestamp + "." + raw_body
signature = base64( HMAC-SHA256(secret, signed_content) )
header value = "v1," + signature
A delivery looks like this (example values):
POST /webhooks/payments HTTP/1.1
Host: api.example.com
Content-Type: application/json
webhook-id: msg_2KWPBgLlAfxdpx2AI54pPJ85f4W
webhook-timestamp: 1674087231
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{"type":"payment.succeeded","data":{"id":"pay_123","amount":1000}}
Your server re-computes the HMAC from the raw body and the two headers, and compares. If they match, the message is authentic and unmodified.
The blind spot: an HMAC proves who sent a message and that it wasn't altered. It is stateless, so it says nothing about how many times you've already acted on it. If an attacker resends the captured bytes five seconds later, the secret is the same, the body is the same, the headers are the same, and the computed HMAC matches perfectly. Cryptographically, it is a valid message, because it is one.
4. What Real Providers Sign (and Don't)
Every provider invented its own scheme before Standard Webhooks, and the differences matter for replay protection:
| Provider | Signature header | What's signed | Signed timestamp? | Identifier to dedupe on |
|---|---|---|---|---|
| Standard Webhooks | webhook-signature (v1,<base64>) | id.timestamp.body | Yes | webhook-id |
| Stripe | Stripe-Signature (t=…,v1=…) | timestamp.body | Yes (official libraries default to a 5-minute tolerance) | event.id in the body |
| Slack | X-Slack-Signature (v0=<hex>) | v0:timestamp:body | Yes (X-Slack-Request-Timestamp; docs use a 5-minute check) | An ID from the payload, where the payload type provides one |
| GitHub | X-Hub-Signature-256 (sha256=<hex>) | body only | No | X-GitHub-Delivery (GUID) |
| Shopify | X-Shopify-Hmac-SHA256 (base64) | raw body only | No (none in the documented scheme) | X-Shopify-Webhook-Id |
Two takeaways:
- Providers that sign a timestamp (Standard Webhooks, Stripe, Slack) give you a bounded replay window. After roughly five minutes, a captured request is worthless.
- Providers that sign only the body (GitHub, Shopify) give you no built-in freshness at all. Their HMAC alone can't tell a request from ten seconds ago from one captured last year. For these providers, delivery-ID deduplication isn't a nice-to-have; it is the only replay defense you have, and the ID store has to be durable (Shopify's docs explicitly tell you to check a persistent store).
Also note that, according to Hookdeck's guide, OpenAI's webhooks follow Standard Webhooks with the same five-minute timestamp tolerance, so the pattern in this article applies to a growing number of APIs.
5. Why Timestamps Aren't Enough
Timestamp validation compares the request's timestamp to your clock:
const now = Math.floor(Date.now() / 1000);
const requestTimestamp = Number(req.get('webhook-timestamp'));
const TOLERANCE_SECONDS = 300; // 5 minutes
if (Math.abs(now - requestTimestamp) > TOLERANCE_SECONDS) {
// reject: too old (or too far in the future)
}
Because the timestamp is part of the signed content, an attacker can't "freshen" a captured request. Change the timestamp and the signature no longer matches. Stripe's documentation describes exactly this mechanism, and it does close the long-tail replay: something captured yesterday is useless today.
But look at what the check actually guarantees: that the request is recent. It does not guarantee the request is unique. A five-minute window is 300 seconds, and automated scripts can send many requests per second, so even a modest rate turns one captured delivery into hundreds of duplicates before the timestamp expires.
Threat scenario: the high-speed replay
An e-commerce platform receives a payment.succeeded webhook for order #8492.
T = 0s Provider delivers the event; your server processes it correctly.
T = 1s A debug proxy in front of a staging environment logs the raw request.
T = 2s An attacker who can read those logs starts re-sending it.
T = 2s..299s Every copy:
signature -> VALID (identical bytes)
timestamp -> VALID (still inside 300s)
result -> your side effect runs again
T = 300s Timestamp check finally starts rejecting the copies.
The Svix documentation on replay attacks makes the same point: the tolerance window still leaves a few minutes in which a captured message can be replayed successfully, and the specification's recommended remedy is to track the webhook-id as an idempotency key.
6. Two Different Windows: Replays vs. Provider Retries
Most guides collapse two separate problems into one. This distinction is what determines how long you must remember an ID.
A replay is a copy of an already-delivered attempt. Its timestamp and signature are frozen, so it can only succeed until the timestamp leaves your tolerance window.
A provider retry is a new attempt of the same event. The Standard Webhooks specification says the timestamp reflects the attempt (it changes on every retry) while the ID stays the same across all retries. Stripe behaves the same way: it generates a fresh timestamp and signature for each delivery attempt.
| Replay of a captured request | Legitimate provider retry | |
|---|---|---|
| Who sends it | Attacker, bot, or buggy proxy | The provider |
| Timestamp / signature | Identical to the original | Fresh on every attempt |
| ID | Same | Same |
| How late can it arrive? | Until the timestamp leaves the tolerance window (about 5 min) | As long as the provider's retry schedule runs |
| Minimum ID retention | Tolerance + clock-skew buffer | The provider's full retry window |
The retry windows are long. Stripe retries live-mode deliveries for up to three days with exponential backoff. The Standard Webhooks spec's example schedule stretches to about 75 hours, and a hosted guide to OpenAI webhooks describes retries for up to 72 hours.
Consequence: a Redis TTL of "tolerance + 30 seconds" (a figure you'll often see) is enough to stop replays, but if a provider retries an event after that key has expired, the retry arrives with a fresh timestamp, passes every check, and gets processed a second time.
You have two good options, and the best setups use both:
- Keep processed IDs in Redis for longer than the provider's retry window (days, not minutes). The code in this article does this.
- Back the cache with a durable unique constraint in your database, so correctness never depends on cache retention.
(The Standard Webhooks spec's own example of saving IDs "in redis for 5 minutes" is a reasonable minimum for replay protection alone; it is not designed to absorb multi-day retries.)
7. Architecture: A Distributed Webhook Nonce Cache
A nonce ("number used once") is a unique identifier attached to a single message. In webhooks this is the event or delivery ID: webhook-id for Standard Webhooks, event.id for Stripe, X-GitHub-Delivery for GitHub, X-Shopify-Webhook-Id for Shopify.
If a provider gives you no ID at all, the fallback is to derive one by hashing the raw body together with the timestamp header. That catches byte-identical replays, but not provider retries (their timestamps differ), so treat it as a last resort.
Why local in-memory storage fails
A JavaScript Set or Go map inside your process is not enough in production:
- State isolation: requests are load-balanced across instances. Node A knows nothing about what Node B processed.
- Short lifecycle: serverless instances and rolling deploys erase local memory.
- Race conditions: two copies of the same request hitting different instances at the same moment both pass a local check.
Why Redis is a good fit
- Atomic conditional write.
SET key value NX EX secondscreates the key only if it doesn't already exist and sets its expiry, in one atomic step. Two simultaneous requests can never both succeed. (Redis has deprecated the olderSETNXcommand since version 2.6.12 in favor ofSETwith theNXoption.) - Automatic expiry keeps memory bounded.
- Low latency. One round trip to a nearby Redis instance is typically about a millisecond, though this depends on your network.
The request flow
Incoming webhook
|
v
+---------------------------+
| 1. Timestamp in window? | -- no --> 400
+---------------------------+
| yes
v
+---------------------------+
| 2. HMAC signature valid? | -- no --> 401
+---------------------------+
| yes
v
+---------------------------+
| 3. Redis: SET id |
| 'processing' NX EX 60 |
+---------------------------+
| |
| key created | key already exists
v v
Process event state == 'done' -> 200 (duplicate, ignored)
| state == 'processing' -> 409 + Retry-After
|
+-- success --> SET id 'done' EX <days> -> 200
+-- failure --> DEL id -> 500 (provider will retry)
Two design details are worth calling out because they're easy to get wrong:
Verify the signature before touching Redis. Otherwise anyone on the internet can fill your cache with junk IDs.
Don't burn the nonce before the work succeeds. A common implementation sets the key once and returns "duplicate" forever after. If your handler then crashes or your database hiccups, you return a 500, the provider retries with the same ID, and your endpoint replies "already processed" to an event that was never processed. The event is silently lost. The two-state pattern above (processing with a short TTL, then done with a long TTL, and delete on failure) avoids this.
8. Production Code: Express.js + Redis
The following implements the Standard Webhooks scheme (webhook-id, webhook-timestamp, webhook-signature) with a Redis nonce cache. It has been tested against a local Redis server, including a concurrent burst of 50 identical requests (exactly one is processed).
Setup
npm install express ioredis
Set "type": "module" in your package.json to use the import syntax below.
Note: don't
npm install crypto.cryptois a built-in Node.js module, and the package of that name on npm is a deprecated placeholder. Justimport crypto from 'node:crypto'.
Generate a signing secret in the Standard Webhooks format (the specification calls for 24 to 64 random bytes, base64-encoded, with a whsec_ prefix):
node -e "console.log('whsec_' + require('crypto').randomBytes(32).toString('base64'))"
webhookHandler.js
import express from 'express';
import crypto from 'node:crypto';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379');
// --- Configuration -----------------------------------------------------------
// Standard Webhooks secrets look like "whsec_<base64>"; the HMAC key is the
// base64-decoded part after the prefix.
const SECRET_BYTES = Buffer.from(
(process.env.WEBHOOK_SECRET ?? '').replace(/^whsec_/, ''),
'base64'
);
const TOLERANCE_SECONDS = 300; // reject timestamps further than 5 min from now
const CLOCK_SKEW_BUFFER_SECONDS = 30;
const IN_FLIGHT_TTL_SECONDS = 60; // how long one worker may "own" an event while processing
const DONE_TTL_SECONDS = 4 * 24 * 60 * 60; // must outlive the provider's retry schedule (see article)
// --- Helpers -----------------------------------------------------------------
function signatureMatches(headerValue, id, timestamp, rawBody) {
const expected = crypto
.createHmac('sha256', SECRET_BYTES)
.update(`${id}.${timestamp}.`)
.update(rawBody) // raw bytes: never re-serialize parsed JSON
.digest();
// The header can carry several space-separated signatures (secret rotation).
// Only "v1,<base64>" (HMAC-SHA256) is handled here; other schemes are ignored.
return headerValue.split(' ').some((part) => {
const [version, b64] = part.split(',');
if (version !== 'v1' || !b64) return false;
const candidate = Buffer.from(b64, 'base64');
return candidate.length === expected.length &&
crypto.timingSafeEqual(candidate, expected);
});
}
// --- Middleware: signature + timestamp + nonce claim ----------------------------
async function verifyWebhook(req, res, next) {
const id = req.get('webhook-id');
const timestamp = req.get('webhook-timestamp');
const signature = req.get('webhook-signature');
if (!id || !timestamp || !signature || !Buffer.isBuffer(req.body) || req.body.length === 0) {
return res.status(400).json({ error: 'Missing webhook headers or body' });
}
if (!/^\d{1,12}$/.test(timestamp)) {
return res.status(400).json({ error: 'Invalid timestamp' });
}
// Pillar 2: signed timestamp inside tolerance (both directions)
const now = Math.floor(Date.now() / 1000);
const ts = Number(timestamp);
if (Math.abs(now - ts) > TOLERANCE_SECONDS) {
return res.status(400).json({ error: 'Timestamp outside tolerance window' });
}
// Pillar 1: HMAC signature (constant-time)
if (!signatureMatches(signature, id, timestamp, req.body)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Pillar 3: atomic single-use claim in Redis. Only runs AFTER the signature
// is verified, so unauthenticated traffic can't fill the cache.
const key = `webhook:nonce:${id}`;
try {
const claimed = await redis.set(key, 'processing', 'EX', IN_FLIGHT_TTL_SECONDS, 'NX');
if (claimed !== 'OK') {
const state = await redis.get(key);
if (state === 'done') {
// Already handled successfully: acknowledge so the sender stops retrying.
return res.status(200).json({ status: 'duplicate_ignored' });
}
// Another worker is processing this event right now. Ask the sender to retry later.
res.set('Retry-After', '30');
return res.status(409).json({ status: 'in_flight' });
}
} catch (err) {
// Fail closed: if the nonce store is down, don't process. The sender will retry.
console.error('Nonce store unavailable', err);
res.set('Retry-After', '30');
return res.status(503).json({ error: 'Temporarily unavailable' });
}
req.webhook = { id, timestamp: ts, key };
next();
}
// --- Route -------------------------------------------------------------------
const app = express();
app.post(
'/webhooks/payments',
express.raw({ type: 'application/json', limit: '1mb' }), // must run before any JSON parser
verifyWebhook,
async (req, res) => {
const { id, key } = req.webhook;
try {
const event = JSON.parse(req.body.toString('utf8'));
// Your business logic goes here. Make it idempotent as well, for example
// INSERT ... ON CONFLICT (event_id) DO NOTHING inside the same DB transaction.
await handleEvent(event);
// Success: keep the id long enough to absorb provider retries and replays.
await redis.set(key, 'done', 'EX', DONE_TTL_SECONDS);
return res.status(200).json({ status: 'processed', id });
} catch (err) {
console.error(`Processing failed for ${id}`, err);
// Release the claim so the provider's next retry is allowed to run.
await redis.del(key).catch(() => {});
return res.status(500).json({ error: 'Processing failed' });
}
}
);
async function handleEvent(event) {
// e.g. await db.payments.insertOnConflictDoNothing({ eventId: event.id, ... })
}
app.listen(3000, () => console.log('Listening on :3000'));
What this code does differently from typical examples
- Uses the real Standard Webhooks format. The signed content is
id.timestamp.body, the header can contain several space-separatedv1,<base64>signatures (needed for zero-downtime secret rotation), and the secret is base64-decoded after removing thewhsec_prefix. - Compares raw bytes in constant time.
crypto.timingSafeEqualon decoded buffers, after a length check, and the HMAC runs over the raw requestBuffer, never over re-serialized JSON. - Claims the nonce only after authentication, using
SET ... NX EXfor an atomic check-and-set. - Two-state claim (
processingthendone) so failed work can be retried by the provider. - Distinguishes duplicates from in-flight work. A completed duplicate gets a
200so the provider stops retrying. A concurrent copy of something still being processed gets a409withRetry-After; the specification treats non-2xx responses as failures to be retried, so a legitimate retry is not lost. - Fails closed. If Redis is unreachable, the endpoint returns
503rather than processing without deduplication. - Keeps
DONE_TTL_SECONDSlonger than the provider's retry schedule, as explained in section 6.
Your business logic should still be idempotent on its own. For example, insert the event ID into a table with a unique constraint inside the same transaction as your state change:
INSERT INTO processed_events (event_id, processed_at)
VALUES ($1, now())
ON CONFLICT (event_id) DO NOTHING;
-- if 0 rows were inserted, this event was already handled: skip the side effects
9. Testing Your Replay Protection
Don't assume it works; try to break it. This script signs a webhook and sends it twice (adjust the URL and secret):
import crypto from 'node:crypto';
const SECRET = process.env.WEBHOOK_SECRET; // whsec_...
const key = Buffer.from(SECRET.replace(/^whsec_/, ''), 'base64');
const id = `msg_${crypto.randomUUID()}`;
const body = JSON.stringify({ type: 'payment.succeeded', data: { id: 'pay_123' } });
async function send() {
const ts = Math.floor(Date.now() / 1000);
const sig = crypto.createHmac('sha256', key).update(`${id}.${ts}.${body}`).digest('base64');
const res = await fetch('http://localhost:3000/webhooks/payments', {
method: 'POST',
headers: {
'content-type': 'application/json',
'webhook-id': id,
'webhook-timestamp': String(ts),
'webhook-signature': `v1,${sig}`,
},
body,
});
console.log(res.status, await res.text());
}
await send(); // 200 {"status":"processed", ...}
await send(); // 200 {"status":"duplicate_ignored"}
Also test these cases:
| Test | Expected result |
|---|---|
| Same request, sent twice | Business logic runs once |
| Body changed by one character, old signature | 401 |
| Correct signature, timestamp 10 minutes old | 400 |
| Handler throws, then the provider retries with the same ID | First 500, retry is processed |
| 50 identical requests fired concurrently | Exactly one processed |
| Same ID, new timestamp and signature (simulated retry) | Treated as a duplicate |
| Redis stopped | 503, nothing processed |
10. Adapting the Pattern to Stripe, GitHub and Shopify
The claim, process, mark-done flow is provider-independent. Only two things change: how you verify the signature, and where you get the ID.
Stripe. Use the official library for verification (it enforces the 5-minute tolerance by default), then dedupe on event.id:
const event = stripe.webhooks.constructEvent(
req.body, // raw Buffer
req.get('stripe-signature'),
endpointSecret
);
const key = `webhook:stripe:${event.id}`;
// ...then reuse the same SET NX -> process -> mark done flow
Stripe's documentation adds a subtle caveat: in some cases two separate Event objects are generated for the same underlying occurrence, so event-ID dedupe alone won't catch them. For those, combine the ID of the object in data.object with the event type. Stripe also warns that events are not guaranteed to arrive in the order they were generated, and that you shouldn't use the created timestamp to decide whether you've already handled an event. Track IDs instead.
GitHub. GitHub signs the body only (X-Hub-Signature-256, HMAC-SHA256 hex with a sha256= prefix) and includes no timestamp among its delivery headers, so there's no built-in replay window at all. Use X-GitHub-Delivery as the ID and keep it in a durable store, not a short-TTL cache.
Shopify. Verify the base64 HMAC in X-Shopify-Hmac-SHA256 over the raw body, and dedupe on X-Shopify-Webhook-Id. Shopify's documentation recommends checking a persistent store for that ID and skipping the delivery if it exists. Note that when you have multiple subscriptions for the same topic, each delivery gets a different webhook ID but shares the same X-Shopify-Event-Id.
For every provider, namespace your keys (webhook:<provider>:<endpoint>:<id>) so IDs from different sources or tenants can never collide.
11. Edge Cases and Resilience
1. Redis failure modes: fail closed or fail open?
If Redis is unreachable you must choose:
- Fail closed (the code above): reject with
503. Providers retry, so events aren't lost, and an attacker can't exploit an outage to replay freely. Best when duplicates cost money. - Fail open: log a security alert, fall back to a database unique constraint, and process. Best when downtime is costlier than the small risk of a duplicate.
2. Redis is not a durable ledger
Redis replication is asynchronous by default. The primary acknowledges a write before it reaches replicas, so if it fails in that gap, a promoted replica may not have your latest nonce keys. The Redis documentation notes that the WAIT command can request acknowledgement from a number of replicas, but it explicitly does not make Redis a strongly consistent system: acknowledged writes can still be lost during failover depending on your persistence configuration. A restart without persistence loses the entire cache too.
Practical takeaway: treat Redis as the fast first line of defense, and put a unique constraint in your database as the source of truth. That combination survives failovers, flushes, and TTL mistakes.
3. Slow handlers and lock expiry
The processing state expires after IN_FLIGHT_TTL_SECONDS. If your handler runs longer than that, a duplicate could start while the first is still running. Stripe and Shopify both recommend returning 2xx quickly and doing the real work asynchronously. A robust pattern is to verify, claim the ID, enqueue the job (using the event ID as the job ID), and respond; the queue then owns retries. The Standard Webhooks spec suggests senders use request timeouts of 15 to 30 seconds, so stay well inside that.
4. Clock drift
If your server's clock drifts from the provider's, legitimate requests fail the timestamp check. Run NTP (or chrony) on every host and container node, and alert on drift. Stripe's documentation recommends NTP for exactly this reason. Never "fix" drift by disabling the recency check: Stripe explicitly warns that a tolerance of 0 turns the check off entirely.
5. Raw body pitfalls
Parsing JSON before verifying the signature is the classic bug. Different parsers order keys, escape Unicode, and handle whitespace differently, so an HMAC over JSON.stringify(req.body) will fail intermittently or always. The Standard Webhooks spec calls out this exact failure mode, and Stripe and Shopify both warn about it. Register express.raw() on the webhook route before any global express.json().
// WRONG: consumes and re-serializes the body before verification
app.use(express.json());
// RIGHT: keep the raw Buffer on webhook routes
app.post('/webhooks/payments', express.raw({ type: 'application/json' }), handler);
6. Secret rotation
Design verification to accept multiple signatures and multiple secrets from day one. Standard Webhooks senders can include several space-separated signatures during rotation, and Stripe lets you keep the previous secret active for up to 24 hours while it signs with both.
7. Don't leak the material attackers need
Since logs and proxies are the most common source of captured requests, scrub the signature headers and bodies of webhook requests from access logs and error trackers, and keep any retained copies short-lived and access-controlled.
8. Ordering is a separate problem
A nonce cache prevents duplicates. It doesn't fix ordering: Stripe explicitly doesn't guarantee events arrive in the order they were generated. Fetch current state from the provider's API, or compare version numbers, rather than assuming order.
12. Webhook Hardening Checklist
- HTTPS only. Signatures prove authenticity, not confidentiality.
- Raw body preserved for signature computation.
- Constant-time comparison (
crypto.timingSafeEqualor equivalent). - Signed timestamp with a tolerance window (5 minutes is the common default), never disabled.
- Signature verified before any cache or database write.
- Atomic nonce claim (
SET key value NX EX ttl) on the event or delivery ID. - Two-state claim: release the key on failure so provider retries aren't swallowed.
- ID retention longer than the provider's retry window (days, not minutes), or a durable database constraint.
- Database unique constraint on event ID as the source of truth.
- Return
2xxfast, and process asynchronously through a queue. - Fail-closed or fail-open decision documented for Redis outages.
- NTP running on all nodes, with clock-drift alerts.
- Secret rotation supported (multiple signatures and secrets).
- Per-endpoint secrets stored in a secrets manager, never in source control.
- Signed request material scrubbed from logs and error trackers.
- Provider IP allowlisting where the provider publishes ranges (Stripe does), as an additional layer next to signature verification, not a replacement for it.
Conclusion
Signature verification proves who sent a webhook and that it wasn't altered. A signed timestamp limits how long a captured request stays useful. Neither tells you whether you've already acted on the message, and inside the tolerance window a replay is indistinguishable from the original.
Closing that gap takes a nonce cache: verify the signature, atomically claim the event ID, do the work, and mark it done, with failure releasing the claim. Get the retention right (long enough to absorb provider retries, not just replays), back it with a database constraint, and fail in the direction your business can tolerate. That is defense in depth against duplicate transactions, corrupted state, and abused resources, whether the duplicate came from an attacker or from a well-meaning retry.
13. References
- Standard Webhooks specification v1.0.0: signature scheme, headers, timestamp vs. ID semantics, retry schedule, HTTPS guidance
- Stripe: Receive Stripe events in your webhook endpoint: signature verification, replay prevention, retries, duplicate events, ordering
- GitHub Docs: Webhook events and payloads (delivery headers) and Validating webhook deliveries
- Shopify: Verify webhook deliveries: HMAC header and duplicate detection
- Slack: Verifying requests from Slack:
v0signature and timestamp check - Redis: SET command and SETNX (deprecated)
- Redis: Replication: asynchronous replication and the
WAITcommand - Svix: What is a replay attack?
- Hooklistener: Stripe webhook security guide: threat model for captured payloads
- Hookdeck: Guide to OpenAI webhooks
- npm:
crypto(deprecated placeholder for the built-in module)
Provider behavior changes over time. Re-check each provider's current documentation before relying on specific defaults such as tolerance windows or retry schedules.