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.

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

# 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