# Stripe Webhook Signature Verification

> Use when validating incoming Stripe webhook requests in a Node.js or Next.js backend before processing any payment event. Verifies the `stripe-signature` header against `STRIPE_WEBHOOK_SECRET` using Stripe's HMAC-SHA256 scheme, and rejects replays older than 300 seconds. Do NOT use for general HTTP signature validation (use a generic crypto-signature skill), for processing the webhook payload after signature is confirmed (use payment-provider-router), or for Stripe API calls that are not webhook-driven.

- Skill: `jacob-balslev/stripe-webhook-signature-verification` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add jacob-balslev/stripe-webhook-signature-verification`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jacob-balslev/stripe-webhook-signature-verification/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- License: MIT
- Author: jacob-balslev (https://skillmd.com/u/jacob-balslev)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/jacob-balslev/stripe-webhook-signature-verification

---


# Stripe Webhook Signature Verification

## Concept of the skill

**What it is:** The security check that proves an incoming Stripe webhook was signed by Stripe before any payment logic runs.
**Mental model:** The raw request body, signature header, and webhook secret form one verification tuple; change any part and the event is untrusted.
**Why it exists:** Webhook routes are public endpoints that can trigger billing and fulfillment, so authenticity has to be established before routing.
**What it is NOT:** It is not payment-event routing, general HTTP signature validation, or Stripe API usage outside webhook delivery.
**Adjacent concepts:** HMAC verification, raw request bodies, replay tolerance, idempotency keys.
**One-line analogy:** It is the seal check before opening the payment envelope.
**Common misconception:** Parsing JSON first is harmless; transforming the raw bytes invalidates the signature comparison.

## Coverage

- The raw-body requirement — why `stripe.webhooks.constructEvent()` requires the unparsed `Buffer` from the request body, and how Next.js App Router routes expose it via `request.arrayBuffer()`
- HMAC-SHA256 verification — how `constructEvent(rawBody, signature, secret)` reconstructs and compares the Stripe signature internally
- Replay protection — the 300-second tolerance window Stripe checks against the `t=` timestamp embedded in the `stripe-signature` header; when to tighten it
- Environment-specific secrets — `STRIPE_WEBHOOK_SECRET` for production vs `whsec_...` from the Stripe CLI `--forward-to` session in development; why they must never be swapped
- Idempotency key pattern — recording the `event.id` in Postgres before processing so a retried delivery does not double-charge or double-fulfill

## Philosophy of the skill

A webhook that skips signature verification is an unauthenticated public endpoint that can trigger payment processing. The verification step is load-bearing security, not a convenience check. Stripe's SDK makes verification a single call, but two failure modes are common in practice: the request body gets parsed (by a body-parser middleware) before the raw bytes reach the verification call, which silently corrupts the HMAC comparison; and the wrong webhook secret is loaded from environment variables, producing a 400 that is hard to distinguish from a replay rejection. Both failures look the same to the caller — a rejected webhook — and both are invisible until a real event is dropped.

## Verification

1. **Confirm raw body access.** In Next.js App Router: `const rawBody = Buffer.from(await request.arrayBuffer())`. Do NOT pass `await request.json()` or `await request.text()` — both transform the bytes.

2. **Retrieve and verify.**

   ```typescript
   import Stripe from "stripe";
   const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

   const sig = request.headers.get("stripe-signature") ?? "";
   let event: Stripe.Event;
   try {
     event = stripe.webhooks.constructEvent(
       rawBody,
       sig,
       process.env.STRIPE_WEBHOOK_SECRET!
     );
   } catch (err) {
     return Response.json({ error: "Signature verification failed" }, { status: 400 });
   }
   ```

3. **Check idempotency before processing.**

   ```sql
   INSERT INTO webhook_events (event_id, processed_at)
   VALUES ($1, now())
   ON CONFLICT (event_id) DO NOTHING
   RETURNING event_id;
   ```

   If the `RETURNING` clause returns no rows, the event was already processed — return 200 immediately without re-running side effects.

4. **Route the verified event** to `payment-provider-router`.

## Failure Mode Reference

| Failure | Symptom | Fix |
|---------|---------|-----|
| Body parsed before verification | 400 on every real Stripe event | Use `arrayBuffer()`, not `json()` or `text()` |
| Wrong webhook secret | 400 with "No signatures found matching the expected signature" | Verify `STRIPE_WEBHOOK_SECRET` matches the endpoint in the Stripe dashboard |
| Replay attack | 400 with "Timestamp too old" | Legitimate if tolerance is tight; check `t=` value in the `stripe-signature` header |
| Secret from wrong environment | Events verify in dev but fail in production | Use per-environment secrets; never share between environments |

## Do NOT Use When

| Use instead | When |
|---|---|
| `payment-provider-router` | You have a verified event and need to decide which handler processes it |
| `nextjs-server-action-validation` | You are validating user-submitted form data, not a Stripe webhook |
| (a generic HTTP signature skill) | You are verifying webhooks from a non-Stripe provider with a different signing scheme |

