Nonce-based replay protection with Redis
A timestamp tolerance window narrows how long a captured webhook stays replayable, but it does not stop replays inside that window — an attacker who resends a valid payload within the allowed few minutes still gets it accepted twice. This guide closes that gap with a nonce store backed by Redis SET ... NX, giving each signed delivery exactly one chance to be accepted. It sits under the Replay Attack Prevention reference and pairs directly with Preventing webhook replay attacks with timestamps: the timestamp check bounds the window, and the nonce check makes everything inside that window single-use. The scenario is specific — you already verify signatures and timestamps, and you want to guarantee that no signed delivery is ever processed more than once.
The mechanism is one atomic operation. Redis SET key value NX PX ttl writes the key only if it does not already exist (NX) and expires it after ttl milliseconds (PX). The first delivery’s nonce write succeeds and is processed; any replay carrying the same nonce finds the key already present, the SET returns nil, and the request is rejected. Because the operation is atomic, two concurrent copies of the same replay cannot both win the race.
Prerequisites
- Runtime: Node.js 20+ with TypeScript.
- Redis 7+ reachable from every verifier instance, ideally the same instance you may already use for idempotency caching.
- Library:
ioredis(npm i ioredis). - An existing timestamp + signature check, such as the one in Preventing webhook replay attacks with timestamps. The nonce store complements those checks; it does not replace them.
- A per-delivery unique value from the provider — a dedicated nonce header, an event id, or (as a fallback) the signature itself.
Step 1: Derive a stable nonce per delivery
Pick a value that is unique per delivery and stable across replays of the same delivery. A provider-supplied nonce or event id is ideal. If none exists, the signature itself works as a nonce because it is unique per signed payload — but only when timestamp is part of the signed material, so an identical body sent at a new time produces a new signature.
import crypto from 'node:crypto';
export function deriveNonce(headers: Record<string, string | undefined>): string {
const explicit = headers['x-webhook-nonce'] ?? headers['x-webhook-event-id'];
if (explicit) return explicit;
// Fallback: hash the signature so the key length is bounded and uniform.
const sig = headers['x-webhook-signature'];
if (!sig) throw new Error('No nonce source: missing nonce, event-id, and signature');
return crypto.createHash('sha256').update(sig).digest('hex');
}
Engineering Note: Namespace the Redis key (webhook:nonce:<provider>:<nonce>). Sharing a flat key space across providers risks a nonce collision letting one provider’s delivery suppress another’s.
Step 2: Claim the nonce atomically with SET NX
Use a single SET with NX and PX. One round trip both tests and claims the nonce, so there is no check-then-set race even under concurrent replays.
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
// Returns true if THIS call claimed the nonce (first time), false if already seen.
export async function claimNonce(nonce: string, ttlMs: number): Promise<boolean> {
const key = `webhook:nonce:${nonce}`;
// 'OK' on first write, null if the key already exists.
const result = await redis.set(key, '1', 'PX', ttlMs, 'NX');
return result === 'OK';
}
Engineering Note: Never split this into EXISTS then SET. Two concurrent replays would both pass EXISTS before either writes, and both would be accepted. The atomicity of SET ... NX is the entire correctness guarantee.
The race is easiest to reason about with two workers reaching Redis a millisecond apart — the single-command form makes the ordering Redis’s problem, not yours.
Step 3: Set the TTL to the timestamp window
Size the nonce TTL to equal the timestamp tolerance window. Outside that window the timestamp check already rejects the request, so a nonce older than the window can never be replayed successfully — keeping it in Redis only wastes memory. Matching the two bounds means every nonce that could still be replayed is in Redis, and nothing else is.
// Single source of truth shared by the timestamp check and the nonce TTL.
export const TOLERANCE_MS = 300_000; // 5 minutes
// In the handler:
const accepted = await claimNonce(nonce, TOLERANCE_MS);
Engineering Note: If the nonce TTL is shorter than the timestamp window, a replay arriving after the TTL expires but before the timestamp window closes is accepted twice. If it is much longer, you retain dead nonces and grow Redis without benefit. Bind both to one constant. Deriving that constant is covered in choosing a timestamp tolerance window.
Step 4: Order timestamp, signature, then nonce checks
Run the cheap, stateless checks first so Redis only ever sees authentic, in-window deliveries. Claim the nonce last, immediately before processing.
import { Request, Response, NextFunction } from 'express';
export async function replayGuard(req: Request, res: Response, next: NextFunction) {
const headers = req.headers as Record<string, string | undefined>;
// 1. Timestamp window (rejects most replays for free, no Redis call).
const ts = Number(headers['x-webhook-timestamp']);
if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > TOLERANCE_MS) {
return res.status(400).json({ error: 'Timestamp outside tolerance window' });
}
// 2. Signature (assume verifyHmac validated the raw body earlier in the chain).
// Only authentic requests reach the nonce store.
// 3. Nonce claim — the single-use gate.
const nonce = deriveNonce(headers);
const fresh = await claimNonce(nonce, TOLERANCE_MS);
if (!fresh) {
return res.status(409).json({ error: 'Replay detected: nonce already used' });
}
next();
}
Engineering Note: Ordering is a DoS control as much as a correctness one. If you claimed nonces before checking the timestamp and signature, an attacker could flood Redis with writes using forged requests. Spend the Redis write only after the request proves it is authentic and current.
Verification and testing
- First-vs-replay: send a valid signed delivery twice; assert
200then409. - Concurrency: fire the same delivery from two parallel clients; assert exactly one
200and one409, never two200s. This is the test that proves theNXatomicity, not just the happy path. - TTL alignment:
redis-cli PTTL webhook:nonce:<nonce>immediately after acceptance should report a value at or just belowTOLERANCE_MS. - Window expiry: a delivery with a timestamp older than the window must be rejected at step 1 and must not create a Redis key — confirm with
redis-cli EXISTSafter the rejected request.
# Replay the exact same delivery twice and compare status codes.
PAYLOAD='{"event":"payment.captured"}'
for i in 1 2; do
curl -s -o /dev/null -w "%{http_code}\n" \
-H "X-Webhook-Timestamp: $(date +%s%3N)" \
-H "X-Webhook-Nonce: evt_fixed_123" \
-H "X-Webhook-Signature: sha256=..." \
-d "$PAYLOAD" https://api.yourdomain.com/webhooks
done
# Expect: 200 then 409
Failure modes and gotchas
- Check-then-set instead of atomic SET NX. Splitting the claim into
EXISTS+SETreopens the replay race under concurrency — two simultaneous replays both passEXISTS. Always claim with one atomicSET ... NX PX. - Nonce TTL not matching the timestamp window. A TTL shorter than the window lets a late replay through after the key expires; a longer TTL just bloats Redis. Derive both from one constant.
- Redis unavailability with a fail-open default. If Redis is down and you let requests through to avoid dropping traffic, you have silently disabled replay protection. Decide deliberately: fail closed (reject with
503) for high-value events, and alert loudly either way. - Unstable nonce source. Deriving the nonce from a value that differs between a delivery and its replay (for example a per-request trace id the provider regenerates) defeats the check entirely. Verify your nonce source is identical across the original and any genuine retry before trusting it.
Frequently Asked Questions
Should the nonce be claimed before the handler runs or after it succeeds?
Claiming first is the safe default, but it means a handler that crashes mid-processing has already consumed the nonce and the producer's genuine retry is rejected as a replay. When that loss is unacceptable, claim with a deliberately short TTL and rewrite the key with the full TTL once processing commits, so a crash lets the retry back in after a few seconds. Claiming only after processing is never correct, because it leaves the whole handler duration open to a concurrent duplicate.
How much memory does the nonce store need, and does the eviction policy matter?
Size it as arrival rate multiplied by TTL: 2,000 deliveries per second against a 300-second TTL is 600,000 live keys, comfortably under a gigabyte with fixed-length hex keys. The eviction policy matters far more than the total, because under an allkeys-lru maxmemory policy Redis discards keys that have not expired and silently reopens the replay window with no error surfacing anywhere. Run this store with eviction disabled and alert on memory, so pressure appears as a failed claim rather than as lost protection.
Do we need AOF persistence for the nonce store?
A restart without persistence empties the store, and every delivery still inside its tolerance window becomes replayable exactly one more time. Whether that matters is an event-value question: fsyncing once a second bounds the loss to roughly a second of claims at a small write cost, while running purely in memory trades a full window of exposure for the fastest possible claim. Note also that a planned restart can be made safe without persistence at all by draining traffic for one window length beforehand.
Does this work on Redis Cluster, and can the claim be served by a replica?
Cluster is fine, because the claim touches a single key that routes to one hash slot and no cross-slot operation is involved. Replicas are not fine: replication is asynchronous, so a claim evaluated against a replica can miss a write that already landed on the primary and admit the replay you were trying to stop. Direct every claim at the primary and reserve replicas for read-only reporting.
Is there any value in storing something richer than a placeholder as the nonce value?
Yes, and at this key size it costs nothing. Record the receipt time and a short request identifier so a rejected duplicate can be traced back to the delivery that legitimately claimed the nonce, which turns an opaque rejection into something you can investigate. Redis 7 can return the previous value in the same round trip as the conditional write, so the rejection path learns when the original arrived without spending a second command.
How much latency does the claim add, and what timeout should it use?
One round trip, which is a fraction of a millisecond inside an availability zone and a few milliseconds across one, so the claim is rarely the bottleneck in a handler. The failure case is what hurts: a default client timeout measured in seconds holds the connection open long past the point where the sender has given up and retried. Set the command timeout well below the sender's delivery timeout so a degraded store fails fast instead of manufacturing the duplicates it exists to prevent.
Related
- Preventing webhook replay attacks with timestamps — the stateless gate that runs before this one.
- Choosing a timestamp tolerance window — how to pick the single constant that sizes both the window and this TTL.
- Zero-downtime webhook secret rotation — rotating the signing key without invalidating in-flight deliveries.
- Rotating JWT signing keys with JWKS — the asymmetric equivalent, where
jtireplaces the nonce. - Replay Attack Prevention — the layered threat model this store belongs to.