DEV Community

Praveen Doddamani
Praveen Doddamani

Posted on Originally published at hookarmour.dev

Why Stripe Webhook Signature Verification Fails on Replays (and How to Fix It)

You built a dead-letter queue. Your Next.js app, Express server, or AWS Lambda crashed on a Stripe customer.subscription.updated event, your queue caught the failed payload, and you spent 30 minutes fixing a database migration bug.

You deploy the fix, trigger a replay... and your application immediately throws:

Error: Webhook signature verification failed: Timestamp outside the tolerance zone (1800s > 300s)
Enter fullscreen mode Exit fullscreen mode

Every backend developer who attempts to build a resilient webhook ingestion pipeline runs headfirst into this wall.

Here is why it happens, why common workarounds introduce severe vulnerabilities, and how to architect a zero-loss ingress buffer that downstream handlers can verify without code changes.


## 1. Anatomy of the 300-Second Signature Window

When Stripe dispatches a webhook, it includes the Stripe-Signature header containing a Unix timestamp and an HMAC-SHA256 hash:

Stripe-Signature: t=1711234567,v1=5257a869e7eceeda32a1a243a0b7...
Enter fullscreen mode Exit fullscreen mode

The signature v1 is computed across both the timestamp and the raw unparsed JSON payload:

v1 = HMAC-SHA256(webhook_secret, `${t}.${rawBody}`);
Enter fullscreen mode Exit fullscreen mode

When you call stripe.webhooks.constructEvent(body, sig, secret) in your backend, the SDK performs two independent checks:

  1. Cryptographic Authenticity: Does v1 match the HMAC of ${t}.${rawBody} using your secret?
  2. Replay Protection: Is Math.abs(Date.now() / 1000 - t) <= 300 (within 5 minutes)?

### The Conflict

Replay attacks and legitimate Dead-Letter Queue (DLQ) replays look mathematically identical to Stripe's SDK. If your queue holds an event for longer than 300 seconds (5 minutes), standard signature verification will reject it every single time.


*## 2. Why Common Workarounds Fail
*

Bad Fix 1: Disabling verification on retried events

Some teams add a header like x-replayed: true and skip constructEvent() if the header is present.

Why it fails: Anyone who discovers your webhook URL can send a forged customer.subscription.created payload with x-replayed: true and grant themselves free access without ever hitting Stripe.

Bad Fix 2: Bumping the SDK tolerance window

// Dangerous: Opens a 3-day replay vulnerability
stripe.webhooks.constructEvent(body, sig, secret, 86400 * 3);
Enter fullscreen mode Exit fullscreen mode

Increasing tolerance to 72 hours allows retries to pass, but completely guts replay protection across your entire billing infrastructure, allowing stale or intercepted requests to be replayed.


## 3. The Solution: Ingress Verify & Outbound Re-Signing

The correct architecture decouples Ingress Verification from Internal Delivery:

[Stripe] 
   │
   ▼ (Original Stripe Signature, t=now)
[Ingress Proxy] ── verifies signature within 300s window
   │
   ├─► Immediate 200 OK back to Stripe (stops retry timeouts)
   │
   ▼ (Persisted to durable SQLite / WAL storage)
[Outbound Dispatcher] 
   │
   ▼ (Fresh HMAC + Fresh Timestamp t=now + Provenance Headers)
[Downstream Handler (Your App)] ── constructsEvent() passes cleanly!
Enter fullscreen mode Exit fullscreen mode

### The Step-by-Step Flow:

  1. At Ingress (<10ms): The proxy receives the webhook from Stripe. It uses your endpoint's signing secret to verify the original signature within the 300s tolerance window. It rejects fake requests upfront and acknowledges Stripe with an immediate 200 OK.
  2. Durable Storage: The uncorrupted payload and metadata are written to persistent local storage (e.g. SQLite with WAL mode).
  3. At Forward / Replay: When delivering the event to your backend (whether 5ms later or 3 days later from a DLQ), the proxy computes a fresh timestamp t = Math.floor(Date.now() / 1000) and generates a valid v1 HMAC using your secret.

The Re-Signing Function:

const crypto = require('crypto');

function signStripeHeaders(rawBody, headers, secret) {
  const freshTimestamp = Math.floor(Date.now() / 1000);
  const freshSignature = crypto
    .createHmac('sha256', secret)
    .update(`${freshTimestamp}.${rawBody}`)
    .digest('hex');

  return {
    ...headers,
    'stripe-signature': `t=${freshTimestamp},v1=${freshSignature}`
  };
}
Enter fullscreen mode Exit fullscreen mode

## 4. Protecting Against Out-of-Order Overwrites

One risk with replaying delayed webhooks is that a replayed customer.subscription.deleted from 2 days ago might arrive after a fresh customer.subscription.created event from today.

To prevent stale state from overwriting fresh state downstream, the ingress proxy should attach explicit provenance metadata:

x-hookarmor-is-replay: true
x-hookarmor-original-timestamp: 1711234567
x-hookarmor-delivery-id: del_8f92b1c
x-hookarmor-original-stripe-signature: t=1711234567,v1=...
Enter fullscreen mode Exit fullscreen mode

Your downstream app can check x-hookarmor-original-timestamp against your database record's updated_at before applying changes.


## 5. The Outcome: Zero Code Changes in Your App

Because the replayed request arrives with a fresh timestamp and a valid HMAC, your backend handler remains completely stock:

// In your Next.js / Express route:
export async function POST(req) {
  const body = await req.text();
  const sig = req.headers.get('stripe-signature');

  // Passes on initial delivery AND on replay days later:
  const event = stripe.webhooks.constructEvent(
    body, 
    sig, 
    process.env.STRIPE_WEBHOOK_SECRET
  );

  await handleBilling(event);
  return Response.json({ received: true });
}
Enter fullscreen mode Exit fullscreen mode

Open Source Solution: HookArmor

I packaged this exact pattern into HookArmor, an open-source (MIT), single-binary / Docker reverse proxy:

  • Instant 200 OK upstream + local SQLite (WAL) buffer.
  • Automatic exponential retry backoff.
  • Embedded Dead-Letter Queue (DLQ) with a web UI for 1-click manual replays.
  • Automatic transparent Stripe re-signing with provenance headers.
  • Zero external database dependencies.

You can run it in front of your app with Docker Compose:

version: '3.8'
services:
  hookarmor:
    image: node:20-alpine
    working_dir: /app
    command: sh -c "npm install -g hookarmor && hookarmor start --port 8080 --target http://my-app:3000/webhooks"
    ports:
      - "8080:8080"
    volumes:
      - ./data:/app/data
    environment:
      - HOOKARMOR_TARGET_URL=http://my-app:3000/webhooks
      - HOOKARMOR_UI_PASSWORD=admin
Enter fullscreen mode Exit fullscreen mode

GitHub: https://github.com/pkdoddamani/hookarmor

npm: https://www.npmjs.com/package/hookarmor

How does your team currently handle delayed webhook retries and signature expiration? Would love to hear other architectural approaches in the comments!

Top comments (0)