obsidian/wiki/concepts/webhook-replay-attack-prevention.md
2026-05-10 22:37:43 +01:00

3.7 KiB

name description type
webhook-replay-attack-prevention Timestamp validation (±5min window) + request deduplication (per-phone nonce/ID) to prevent replay attacks on webhook endpoints concept

Webhook Security — Replay Attack Prevention

What Is a Replay Attack?

An attacker captures a legitimate, HMAC-signed webhook request and re-sends it later. Even if HMAC validation passes (the signature is valid), the request should be rejected because it was already processed.

Two-Layer Defense

Layer 1 — Timestamp Validation (±5 Minute Window)

The webhook sender includes a timestamp in the payload or headers. The receiver rejects requests where the timestamp is more than 5 minutes old:

const MAX_AGE_MS = 5 * 60 * 1000  // 5 minutes

export async function POST(req: NextRequest) {
  const body = await req.json()
  const { timestamp, callId, phone, ...data } = body

  // 1. Timestamp validation
  const age = Date.now() - new Date(timestamp).getTime()
  if (Math.abs(age) > MAX_AGE_MS) {
    return Response.json({ error: 'Request expired' }, { status: 400 })
  }

  // 2. HMAC validation (must happen before any DB access)
  const signature = req.headers.get('X-Binotel-Signature') ?? ''
  if (!verifyHmac(body, signature)) {
    return Response.json({ error: 'Invalid signature' }, { status: 401 })
  }

  // 3. Deduplication (see Layer 2)
}

Layer 2 — Request Deduplication (Nonce / Idempotency Key)

Even within the 5-minute window, an attacker could replay a request several times. Use a nonce or call ID stored in the DB:

// After timestamp + HMAC validation...

// Check if this exact call was already processed
const existing = await payload.find({
  collection: 'processed-webhooks',
  where: { callId: { equals: callId } },
  limit: 1,
})

if (existing.docs.length > 0) {
  return Response.json({ status: 'duplicate' }, { status: 200 })
  // Return 200 (not 4xx) to prevent the sender retrying
}

// Process the webhook...

// Record the nonce AFTER successful processing
await payload.create({
  collection: 'processed-webhooks',
  data: { callId, processedAt: new Date().toISOString() },
})

Alternative — Per-Phone Nonce with TTL

If a separate processed-webhooks collection is too heavy, use a Redis SET with TTL:

const nonce = `webhook:${callId}`
const alreadySeen = await redis.set(nonce, '1', 'EX', 300, 'NX')
// NX = only set if not exists, returns null if already set
if (alreadySeen === null) {
  return Response.json({ status: 'duplicate' }, { status: 200 })
}

Vulnerability Without These Measures

Missing defense Attack vector
No timestamp check Attacker stores a signed request, replays it days later
No deduplication Attacker replays a fresh request N times in the valid window
Both missing Any captured request can be replayed indefinitely

HMAC Is Necessary But Not Sufficient

HMAC proves the request came from someone with the secret. It does NOT prove the request is being received for the first time. Replay prevention is always layered on top of HMAC, not a replacement.

Standard Timestamp Header Pattern (Stripe-style)

Many providers (Stripe, GitHub, Binotel) include the timestamp in:

  • A dedicated header: Stripe-Signature: t=1234567890,v1=abc...
  • The JSON payload body

The HMAC is computed over timestamp + "." + raw_body so the timestamp is signed along with the payload. This prevents an attacker from modifying the timestamp on a captured request.

See Also

Source: daily/2026-05-09.md | 2026-05-09