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.
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.
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.