Stripe webhook signature verification in Next.js fails for five reasons in 95% of cases: your framework parsed the body before verification, JSON.parse plus stringify changed the bytes, the timestamp exceeded Stripe's 5-minute tolerance, you copied the wrong endpoint secret (test vs live or wrong endpoint), or your verification code accepts the fake v0 test scheme [1] [2]. Each one returns the same opaque "No signatures found matching the expected signature" error, which is what makes debugging painful.
This is the deep-dive companion to the Stripe Payments with Server Actions guide, which covers the full integration in seven steps. That post tells you how to wire webhooks correctly. This one tells you why your webhooks fail when you have already wired them, plus the idempotency pattern that turns Stripe's 3-day retry behavior from a hazard into a non-event.
TL;DR:
- The 5 failure modes, in order of frequency: body parsed before verification, JSON re-stringify, timestamp drift (>300s), wrong endpoint secret, accepting v0 test signatures.
- Always read
await request.text()first in App Router Route Handlers. Use that exact string for both signature verification and JSON parsing. Never userequest.json()before verification. - Add event.id dedup with a unique constraint on a
stripe_eventstable. Stripe retries failed deliveries for up to three days in live mode [1]; without dedup, one purchase becomes ten database writes. - Test locally with the Stripe CLI before flipping production keys. The Stripe Webhook Verifier tool lets you debug failing signatures without sending the raw secret over the network.
- This is OWASP A02 Cryptographic Failures territory. Webhooks without verification are unauthenticated mutation endpoints anyone with
curlcan call.
Table of contents
- Why does Stripe webhook signature verification fail in Next.js?
- How does the Stripe-Signature header actually work?
- What are the 5 most common causes of verification failure?
- How do you add idempotency dedup correctly?
- What is the production-ready Next.js webhook pattern?
- How do you test webhook signature verification locally?
Why does Stripe webhook signature verification fail in Next.js?
Signature verification fails when the bytes you pass to stripe.webhooks.constructEvent differ, even by a single character, from the bytes Stripe signed. The signature is an HMAC-SHA256 of {timestamp}.{raw_body} keyed with your endpoint secret [2]. If either side of that string drifts, the HMAC drifts, and verification throws.
In Next.js App Router, the failure has a few specific causes that other frameworks don't share. Route Handlers default to parsing JSON when you call request.json(), and once you have called it, the underlying byte stream is consumed. If you read the body parsed and then try to verify, you have already lost the exact bytes. Pages Router had a different version of this problem (bodyParser: false on the route config). App Router has its own.
The fix is the same shape in every case: read the raw body once, as text, before anything else touches it. Then verify, then parse. The order matters more than the implementation. If you forget that order, no amount of fiddling with the secret or the timestamp will save you.
How does the Stripe-Signature header actually work?
The Stripe-Signature header is a comma-separated list of key-value pairs. Two matter: t= (the Unix timestamp when Stripe signed the event) and v1= (the HMAC-SHA256 signature in hex). A real header looks like this:
Stripe-Signature: t=1716105600,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Stripe computes v1 as HMAC_SHA256(secret, "{t}.{raw_body}"). Your handler computes the same thing using your endpoint secret and the raw bytes you received. If both values match, the event is authentic. If they differ, something between Stripe and your code mutated the payload.
For test events, Stripe sends an additional signature with a fake v0 scheme to help with testing your verification setup [2]. Stripe's docs are explicit: "the only valid live signature scheme is v1" [2]. The v0 scheme exists so testing infrastructure can produce known-bad signatures on demand. If your verification code accepts v0, anyone can hand-craft a test-mode signature, send it to your endpoint, and your handler will treat it as authentic. This is failure mode #5 below.
The stripe.webhooks.constructEvent function in the official SDK handles all of this correctly. It verifies v1 only, checks the timestamp against a 5-minute tolerance, and throws on any mismatch. Manual verification (computing the HMAC yourself with Node's crypto module) is where the subtle bugs creep in. Use the SDK.
What are the 5 most common causes of verification failure?
These five account for almost every "No signatures found matching the expected signature" error in production indie SaaS. They appear in roughly this frequency order.
Cause 1: Your framework parsed the body before verification
This is failure mode #1 by a wide margin. In a Next.js App Router Route Handler at app/api/webhooks/stripe/route.ts, the wrong shape looks like this:
export async function POST(request: Request) {
const event = await request.json() // BUG: body consumed here
const signature = request.headers.get('stripe-signature')!
const verified = stripe.webhooks.constructEvent(
JSON.stringify(event), // re-stringified, bytes differ
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
// ...
}
The bug: request.json() consumed the underlying byte stream and produced a JavaScript object. JSON.stringify(event) produces a new string that almost never matches what Stripe sent, because JavaScript's stringify normalizes whitespace, may reorder keys depending on the runtime, and re-escapes characters. The HMAC over the re-stringified bytes is not the HMAC Stripe computed.
The correct pattern reads the raw body as text first:
export async function POST(request: Request) {
const body = await request.text() // raw bytes preserved as a string
const signature = request.headers.get('stripe-signature')!
try {
const event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
// Now safe to JSON.parse(body) or use event.data directly
// event is the verified, parsed Stripe event
} catch (err) {
return new Response('Invalid signature', { status: 400 })
}
}
request.text() returns the body as a string of the exact bytes Stripe sent. The Stripe SDK accepts both Buffer and string for the payload argument and handles the conversion correctly. Always read the body as text. Never call request.json() before verification. Next.js Route Handlers don't have an automatic-body-parser flag to disable, unlike the old Pages Router [5]; the only way to break this is to consume the body yourself.
Cause 2: JSON.parse plus stringify changes the bytes
Even when you read the body as text, you can still corrupt it by passing it through JSON.parse and re-stringifying somewhere in the chain. Stripe's docs are unambiguous: "Stripe requires the raw body of the request to perform signature verification. If you're using a framework, make sure it doesn't manipulate the raw body. Any manipulation to the raw body of the request causes the verification to fail" [2].
The pitfall in App Router specifically: middleware that runs before your Route Handler and rewrites the request. If you have a logger middleware that parses every request body and logs a summary, then re-attaches the body downstream, the re-attached body is JSON.stringify'd. Bytes differ. HMAC differs. Verification fails.
Audit your middleware by grepping for body access in any middleware files:
grep -rn "request\.json\|request\.text\|request\.formData" middleware.ts proxy.ts
If any middleware reads the body, exclude the webhook route from middleware processing. The cleanest pattern is a matcher in middleware.ts that explicitly skips /api/webhooks/*:
export const config = {
matcher: ['/((?!api/webhooks).*)'],
}
This guarantees no middleware ever touches the webhook body. Stripe's signed bytes arrive at your handler untouched.
Cause 3: Timestamp tolerance exceeded
Stripe rejects webhooks whose timestamp is more than five minutes old by default to block replay attacks. The verbatim quote from Stripe's documentation: "Our libraries have a default tolerance of 5 minutes between the timestamp and the current time. You can change this tolerance by providing an additional parameter when verifying signatures" [1].
This bites you when:
- Your server clock has drifted. Container clocks can drift several minutes if the host's NTP sync is broken.
- You're testing with cached events from a few hours ago.
- You're tunneling localhost through a slow tunnel (an early ngrok session, a misconfigured Cloudflare Tunnel) where the event arrives long after Stripe signed it.
The error message from constructEvent in this case is "Timestamp outside the tolerance zone", which is more specific than the generic signature error. If you see that message, the bytes are correct but the clock is the problem. Fix the clock, not the verification code. Loosening the tolerance is the wrong answer in production: a 30-minute tolerance means an attacker who steals one valid webhook payload can replay it 30 times. The right tolerance is 5 minutes; the right fix when it fails is to sync your server clock or replace your tunnel.
Cause 4: Wrong endpoint secret (test vs live, multiple endpoints)
The whsec_ secret is endpoint-specific, not project-specific. Common configurations where this bites:
- You created two webhook endpoints in the Stripe dashboard (one for
localhost, one for production) and yourSTRIPE_WEBHOOK_SECRETenv var holds the wrong one. - You're testing in live mode but your env var holds the test-mode secret.
- You rotated the secret in the dashboard and forgot to update the deployed env var.
- You have separate secrets for separate event types and forgot to merge them after deduplicating endpoints.
The verification error in this case is the generic signature mismatch, indistinguishable at first glance from the body-parsing bugs above. The fast diagnostic: log the first 8 characters of the secret your handler is using (never the full secret), and compare against the first 8 characters shown in the Stripe dashboard for that endpoint. They must match exactly. If they don't, your env var is wrong. Common mistake during deployment: the .env.production overrides .env.local, but only on Vercel; on a self-hosted VPS, the precedence may differ depending on your deploy tool. Verify by checking the actual running value, not the file you intended to deploy.
Cause 5: Accepting the fake v0 test signature in production
If you have written custom signature verification code (not using stripe.webhooks.constructEvent), check whether your code matches against v1= specifically or whether it accepts any v[0-9]+= pattern. Stripe's docs are explicit: "the only valid live signature scheme is v1. To aid with testing, Stripe sends an additional signature with a fake v0 scheme, for test events" [2].
The danger: an attacker can compute a valid v0 signature for any payload they want, because v0 is documented as a fake signature scheme that exists for testing infrastructure. If your handler accepts v0, the attacker forges a payment-succeeded event, signs it with the documented v0 algorithm, and your handler treats it as authentic. The fix is to use stripe.webhooks.constructEvent, which only accepts v1. If you must verify manually for some reason (rarely a good idea), match exactly v1= and reject everything else:
const signatures = signatureHeader
.split(',')
.filter(s => s.startsWith('v1='))
.map(s => s.slice(3))
if (signatures.length === 0) {
return new Response('No v1 signature', { status: 400 })
}
This is one of the OWASP A02 Cryptographic Failures patterns: accepting a weaker or test-grade cryptographic scheme in production [4]. The fix is structural (always use the SDK), not procedural (be more careful).
How do you add idempotency dedup correctly?
Signature verification proves authenticity. Idempotency handles the case where the same valid event arrives multiple times, which happens routinely. Stripe retries failed deliveries for up to three days with exponential backoff in live mode [1]. A single purchase event can hit your endpoint a dozen times across three days if there's even a transient handler failure. Without dedup, that's a dozen rows in your purchases table, a dozen delivery emails sent, and customer support tickets.
The standard pattern: store every processed event.id in a table with a unique constraint, and short-circuit when you've seen it before. Schema:
CREATE TABLE stripe_events (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
processed_at TIMESTAMPTZ DEFAULT NOW()
);
In the handler:
import { createAdminClient } from '@/lib/supabase/admin'
export async function POST(request: Request) {
const body = await request.text()
const signature = request.headers.get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch {
return new Response('Invalid signature', { status: 400 })
}
const admin = createAdminClient()
// Atomic dedup via unique constraint
const { error: insertError } = await admin
.from('stripe_events')
.insert({ id: event.id, type: event.type })
if (insertError?.code === '23505') {
// duplicate key: we've seen this event, skip processing
return new Response('OK (duplicate)', { status: 200 })
}
if (insertError) {
return new Response('DB error', { status: 500 })
}
// Now process, exactly once
await handleEvent(event)
return new Response('OK', { status: 200 })
}
The key detail: insert into stripe_events before processing, not after. If you process first and insert afterwards, two concurrent retries of the same event can both pass the "have we seen this?" check, both process the event, then both try to insert and only one wins. The unique constraint on id gives you atomic dedup at the database level. Concurrent retries race to insert; one wins, the other gets a 23505 (Postgres unique-violation code) and exits cleanly.
The Stripe API idempotency documentation [3] covers the related but distinct concept of API-request idempotency keys (Idempotency-Key header on outbound calls). That's a separate concern from webhook handler idempotency. Both belong in a production-ready integration.
What is the production-ready Next.js webhook pattern?
Combining everything above, the full Route Handler at app/api/webhooks/stripe/route.ts:
import Stripe from 'stripe'
import { createAdminClient } from '@/lib/supabase/admin'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(request: Request) {
// 1. Read raw body as text, preserve bytes
const body = await request.text()
const signature = request.headers.get('stripe-signature')
if (!signature) {
return new Response('Missing signature header', { status: 400 })
}
// 2. Verify signature with the SDK (v1 only, 5-min tolerance)
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
const message = err instanceof Error ? err.message : 'unknown'
console.error('Stripe webhook verification failed', { message })
return new Response('Invalid signature', { status: 400 })
}
// 3. Atomic dedup before processing
const admin = createAdminClient()
const { error: insertError } = await admin
.from('stripe_events')
.insert({ id: event.id, type: event.type })
if (insertError?.code === '23505') {
return new Response('OK (duplicate)', { status: 200 })
}
if (insertError) {
console.error('Stripe webhook dedup insert failed', insertError)
return new Response('DB error', { status: 500 })
}
// 4. Process, exactly once
try {
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutCompleted(event.data.object)
break
case 'payment_intent.payment_failed':
await handlePaymentFailed(event.data.object)
break
default:
// Unhandled event types still get dedup'd and 200'd
break
}
} catch (err) {
// Processing failed: leave the event in stripe_events but log loudly.
// Stripe will retry, but we've already dedup'd, so retries will short-circuit.
// The right pattern for retryable processing failures is to NOT insert into
// stripe_events until after processing succeeds. The choice depends on whether
// you want at-least-once (insert-first) or exactly-once-best-effort (insert-after).
console.error('Stripe webhook handler error', err)
return new Response('Processing error', { status: 500 })
}
return new Response('OK', { status: 200 })
}
The middleware exclusion in middleware.ts:
export const config = {
matcher: ['/((?!api/webhooks).*)'],
}
The Supabase admin client at lib/supabase/admin.ts:
import { createClient } from '@supabase/supabase-js'
export function createAdminClient() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false } }
)
}
This pattern survives every failure mode above. The body is preserved bytes, verification uses the SDK (v1 only, 5-min tolerance), the webhook route is excluded from any middleware that could mutate the request, and dedup is atomic against the database. The handler returns 400 only when verification fails and 200 in every other case, including duplicates and unhandled event types. Returning 200 on duplicates is important: a 500 tells Stripe to retry, which starts the cycle over.
One architectural note: this matches the backend-only data access pattern. The webhook is a Server-side mutation, and it uses the admin client with service_role privileges, never the anon client. The RLS policies on stripe_events should deny all rows to anon and authenticated; only service_role writes here. That's also OWASP A07 Identification and Authentication Failures territory if you get it wrong [4].
How do you test webhook signature verification locally?
Three layers, in order of fidelity:
Layer 1: The Stripe Webhook Verifier tool. Paste a real Stripe-Signature header value, the raw body, and your endpoint secret into the Stripe Webhook Verifier tool. It runs HMAC-SHA256 in your browser, returns pass/fail, and shows you the timestamp age plus a decoded event payload. The whole verification runs locally; nothing leaves your machine. This is the fastest way to confirm a failure is signature-related vs handler-related.
Layer 2: The Stripe CLI listener. Install the Stripe CLI, then in one terminal:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
The CLI prints a new whsec_ secret for the listener session. Use this as your local webhook secret (not the dashboard secret) when developing locally. In a second terminal:
stripe trigger checkout.session.completed
This sends a real signed webhook event to your handler with valid signatures. If verification fails here, the bug is in your handler, not the network. If it passes here but fails in production, the bug is in your production environment (clock drift, wrong secret, middleware interference).
Layer 3: End-to-end with Stripe sandbox. Run an actual test-mode checkout against your deployed staging environment with stripe.checkout.sessions.create({ mode: 'payment', ... }). This catches integration failures the CLI listener can't simulate, like ngrok or Cloudflare Tunnel rewriting the body in transit.
Run layer 1 to confirm verification math, layer 2 to confirm handler logic, layer 3 to confirm the production path. Most indie launches that ship with broken webhooks skipped layer 3 and discovered the problem with the first real customer.
For a broader pre-launch posture, the pre-launch security audit covers webhook signature verification as Check 8 of 12 alongside RLS, Zod, rate limiting, and the security headers configuration. The secure SaaS launch checklist treats signed webhooks as one of the seven non-negotiables before flipping DNS.
If your codebase started as an AI-generated prototype, the webhook handler is a common place to find body-parsing bugs and missing idempotency, both of which the vibe-coded migration playbook audits as part of the payments-path phase. Generated handlers tend to use request.json() (because that's the default Next.js example in the docs the AI was trained on) and skip the dedup table entirely.
This is the integration pattern SecureStartKit ships with by default: raw-body reading, constructEvent verification, atomic dedup against a stripe_events table, middleware exclusion, and service_role admin client for the database write. The webhook handler that ships in the template is the same shape as the production code above. The audit confirms it; the test layers above confirm it stays correct as you extend it.
Frequently Asked Questions
- Why is my Stripe webhook signature verification failing in Next.js?
- The most common cause is that your Route Handler parsed the request body before you ran signature verification. Stripe signs the exact raw bytes it sent; any JSON.parse plus re-stringify produces different bytes (whitespace, key order, escaping) and the HMAC will not match. In Next.js App Router, read await request.text() once and pass that exact string to stripe.webhooks.constructEvent. The other four common causes are timestamp tolerance exceeded (Stripe defaults to 5 minutes), the wrong endpoint secret (test vs live, or you copied the secret from the wrong endpoint), verifying the v0 test signature instead of v1, and using request.json() somewhere upstream that consumed the body.
- What is the difference between v0 and v1 in the Stripe-Signature header?
- v1 is the only valid live signature scheme. v0 is a fake signature Stripe sends alongside v1 for test events to help with testing infrastructure. If your verification code accepts v0, anyone can craft a test-mode webhook with valid v0 bytes and your handler will treat it as authentic. Always verify the v1 scheme. The official stripe.webhooks.constructEvent function does this automatically; manual verification code is where the v0 mistake creeps in.
- How long does Stripe retry failed webhook deliveries?
- In live mode, Stripe retries failed webhook deliveries for up to three days with exponential backoff. In sandbox mode, retries are limited to three attempts over a few hours. The live-mode behavior is why idempotency is non-negotiable. A single purchase that fails on first delivery can hit your endpoint a dozen more times across three days. Without dedup by event.id, that one purchase becomes a dozen rows in your purchases table.
- Do I need idempotency if my webhook handler verifies signatures correctly?
- Yes. Signature verification proves the request came from Stripe and the bytes were not modified. Idempotency handles the case where Stripe successfully sends the same valid event multiple times, which happens during retries, network blips, and occasionally during normal operation. Store event.id in a table with a unique constraint and skip processing if you have already seen it. Without this, retries duplicate database writes, send duplicate emails, and double-credit purchases.
- Can I use stripe.webhooks.constructEvent in a Next.js App Router Route Handler?
- Yes, but you must read the body as raw text first. In the App Router, write `const body = await request.text()` and pass that string to constructEvent along with the Stripe-Signature header and your webhook secret. Do not use request.json() before verification, since that consumes the body and any later body access produces different bytes than what Stripe signed. The verification is synchronous after the body read; if it throws, return a 400 immediately.
Built for developers who care about security
SecureStartKit ships with these patterns out of the box.
Backend-only data access, Zod validation on every input, RLS enabled, Stripe webhooks verified. One purchase, lifetime updates.
References
- Webhooks, Stripe Documentation— docs.stripe.com
- Verify webhook signatures, Stripe Documentation— docs.stripe.com
- Idempotent Requests, Stripe API Reference— docs.stripe.com
- OWASP Top 10:2025— owasp.org
- Route Handlers, Next.js Documentation— nextjs.org
Related Posts
The Secure SaaS Launch Checklist: 7 Non-Negotiables [2026]
Seven security checks every solo dev must verify before going live: auth, RLS, Zod, webhooks, headers, secrets, error handling. The pre-launch audit.
Add Stripe Payments to Next.js with Server Actions
Production-ready Stripe one-time payments in Next.js 16 with Server Actions, Zod, signed webhooks, idempotency via event ID, and delivery email.
Supabase OAuth, Magic Links, MFA in Next.js [2026]
Secure OAuth, magic links, and MFA in Supabase + Next.js. PKCE flow, redirect URL allowlists, AAL2 step-up, and 5 implementation failure modes.