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 unparsedBufferfrom the request body, and how Next.js App Router routes expose it viarequest.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 thestripe-signatureheader; when to tighten it - Environment-specific secrets —
STRIPE_WEBHOOK_SECRETfor production vswhsec_...from the Stripe CLI--forward-tosession in development; why they must never be swapped - Idempotency key pattern — recording the
event.idin 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
Confirm raw body access. In Next.js App Router:
const rawBody = Buffer.from(await request.arrayBuffer()). Do NOT passawait request.json()orawait request.text()— both transform the bytes.Retrieve and verify.
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 }); }Check idempotency before processing.
INSERT INTO webhook_events (event_id, processed_at) VALUES ($1, now()) ON CONFLICT (event_id) DO NOTHING RETURNING event_id;If the
RETURNINGclause returns no rows, the event was already processed — return 200 immediately without re-running side effects.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 |