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.

Redis nonce claim flow A delivery passes timestamp and signature checks, then attempts an atomic SET NX on its nonce; the first wins and is processed while a replay is rejected. Delivery timestamp window + signature ok SET nonce NX PX=ttl atomic claim SET returned OK first delivery -> process SET returned nil replay -> reject 409
Atomic nonce claim: the first delivery's SET NX succeeds and is processed; a replay carrying the same nonce gets nil and is rejected.

Prerequisites

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.

Two concurrent claims on one nonce Worker A and Worker B send the same SET NX command a millisecond apart; Redis serialises them, replying OK to the first and nil to the second so only one worker processes the delivery. Worker A Worker B Redis, single thread SET nonce NX at t=0ms same nonce at t=1ms OK, the claim is yours nil, key already exists processes once returns 200 does no work returns 409
Redis executes the two commands one after the other, so the winner is arbitrary but there is always exactly one — which is the only property the handler needs.

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);
Nonce TTL against the timestamp window A nonce TTL equal to the 300 second timestamp window covers the whole replayable period, while a 180 second TTL expires early and leaves a two minute gap in which a replay is accepted a second time. The TTL has to cover the whole replayable period, exactly timestamp window: 300s nonce TTL 300s: aligned nonce TTL 180s: too short gap: replay accepted a second time t0 t0 + 180s t0 + 300s
The shaded gap between an early-expiring nonce and the still-open timestamp window is exactly the interval in which a replay gets accepted twice.

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

# 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

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.