---
name: webhook-replay-attack-prevention
description: Timestamp validation (±5min window) + request deduplication (per-phone nonce/ID) to prevent replay attacks on webhook endpoints
type: 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:

```ts
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:

```ts
// 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:

```ts
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

- [[wiki/concepts/payload-cms-overrideaccess-bypass]] — must fix access control to prevent exploits even if replay prevention is bypassed

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