Stripe · Security ·
Stripe Webhook Signature Verification Failed in Next.js
Your checkout works, the payment shows up in Stripe, and then your webhook route throws this:
Webhook signature verification failed. Err: No signatures found matching the expected signature for payload.
This guide pairs with Launch Audit, and if you want the next step, read Supabase Service Role Key Exposed? How to Check, Rotate and Stop It Happening Again.
The user paid, but your app never finds out. Stripe's own troubleshooting page puts it simply: if you see this error, at least one of the three values you passed to constructEvent() is wrong. This guide walks through each cause in a Next.js App Router project, in the order you're most likely to hit them, and ends with a working handler and a debug checklist. The Stripe and Next.js behaviour described here comes from their official docs, checked on 14 October 2026 (sources at the end). This site isn't affiliated with Stripe.
What the error means
Stripe signs every webhook it sends and puts the signature in the Stripe-Signature header. The header looks like this:
t=1729000000,v1=5257a869e7ec...,v0=...
t is a timestamp and v1 is an HMAC-SHA256 signature of the timestamp joined to the exact request body, made with your endpoint's signing secret (it starts with whsec_). When you call constructEvent() with the body, the signature and the secret, the library recomputes that signature and compares. It needs three things to be exactly right:
- The raw body: the exact UTF-8 string Stripe sent, byte for byte.
- The signature: the
Stripe-Signatureheader value, unchanged. - The secret: the
whsec_secret for this endpoint, in this mode.
There are two error messages you'll usually see. "No signatures found matching the expected signature for payload" means the header was parsed but nothing matched, so the body or the secret is wrong. "Unable to extract timestamp and signatures from header" means the header itself is missing or malformed.
This check is the only thing proving the request came from Stripe. Treat a failure as a bug in your setup, not an annoyance to switch off.
Cause 1: the body was parsed before verification
This is a common bug in code that AI agents write. In an App Router route handler, the body is a stream you can read once. If you call req.json() and then JSON.stringify() the result to verify it, the keys, whitespace or encoding can change, and the signature no longer matches.
// Wrong: the body has been parsed and re-serialised
const json = await req.json();
stripe.webhooks.constructEvent(JSON.stringify(json), sig, secret);
// Right: verify the exact text Stripe sent
const body = await req.text();
const event = stripe.webhooks.constructEvent(body, sig, secret);
The Next.js docs show request.text() for webhooks in route handlers and note that, unlike Pages Router API routes, you don't need any bodyParser configuration. If you're still on the Pages Router, Stripe's docs say to disable bodyParser for the route and read the raw buffer instead.
Anything that rewrites the body before your handler sees it causes the same failure: a custom server using express.json() before the webhook route, a proxy that re-encodes JSON, or a wrapper that "helpfully" parses every request. We cover this bug pattern, and why agents write it, in 7 security bugs AI agents write in Next.js, Supabase and Stripe.
Cause 2: the wrong signing secret
Stripe's troubleshooting page calls this the most common cause. Every endpoint has its own secret, and several situations hand you a different one:
- Stripe CLI vs Dashboard.
stripe listenprints its ownwhsec_secret in the terminal. It's different from the secret of the endpoint you created in the Dashboard (Workbench, then Reveal secret). Using the CLI secret in production, or the Dashboard secret locally, fails every time. - Test vs live mode. Stripe's docs say that if you use the same endpoint for test and live keys, the secret is different for each.
- Multiple endpoints. If you've registered more than one endpoint, each has its own secret. Copying the secret from endpoint A into the app behind endpoint B won't work.
- The env var isn't what you think. It's missing in production, set only for preview deployments, has a trailing space or newline, or you rolled the secret and forgot to update it.
Stripe suggests printing the secret your code actually uses and comparing it with the source. Do that locally, or log only the first few characters in production; never log the full secret.
Cause 3: the header or the runtime
If you get "Unable to extract timestamp and signatures from header", check how you read the header:
const sig = req.headers.get("stripe-signature");
if (!sig) return new Response("Missing signature", { status: 400 });
Header names are case-insensitive with headers.get(), but a proxy or tunnel that strips custom headers will cause this error too. Print the value and confirm it looks like t=...,v1=....
The runtime matters as well. The synchronous constructEvent() uses Node's crypto. On the Edge runtime, stripe-node needs constructEventAsync() with a Web Crypto provider (Stripe.createSubtleCryptoProvider()); if you call the sync version there, the library tells you to use the async one. The simplest fix is to pin the route to Node:
export const runtime = "nodejs";
Cause 4: timestamp tolerance and clock skew
Stripe includes the timestamp in the signed payload to protect against replay attacks. Stripe's docs say its libraries reject events whose timestamp is more than 5 minutes from the server's current time by default. You'll hit this if your server clock drifts, or if you replay an old saved request with its old header while testing.
The fix is to keep your server's clock in sync (Stripe recommends NTP), not to widen the tolerance. Stripe specifically warns against a tolerance of 0, which turns the recency check off entirely. To test an old event, resend it from the Dashboard or trigger a fresh one with stripe trigger, so it gets a new signature.
Never "fix" it by skipping verification
When a deadline is close, the tempting fix is to delete the constructEvent call and JSON.parse the body. Don't. Stripe's docs spell out what happens: without verification, anyone can send fake events to your endpoint to fulfil orders, grant access or change records. Your webhook URL isn't secret, so a forged checkout.session.completed would give anyone your product for free.
On failure, return a 400, log why it failed (the error message, whether the header was present, which secret variable was loaded), and don't log the full payload or secret. If you want a second layer, Stripe also publishes the IP addresses webhooks come from.
A working Next.js App Router handler
This is a minimal handler: Node runtime, raw text body, verification first, and a fast 2xx.
// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
export const runtime = "nodejs";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: Request) {
const sig = req.headers.get("stripe-signature");
if (!sig) return new Response("Missing signature", { status: 400 });
const body = await req.text(); // raw body, read once
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, endpointSecret);
} catch (err) {
console.error("Stripe signature check failed:", (err as Error).message);
return new Response("Invalid signature", { status: 400 });
}
// Dedupe on event.id before granting anything: Stripe can deliver
// the same event more than once.
switch (event.type) {
case "checkout.session.completed":
// look up the user from data you set server-side, then grant access
break;
}
return new Response("ok", { status: 200 });
}
Two things this skeleton leaves to you. First, idempotency: Stripe's docs say endpoints can receive the same event more than once, so record processed event ids and skip repeats (the same 7-bugs post shows a pattern for that). Second, speed: Stripe asks you to return 2xx quickly and move slow work to a queue, or deliveries can time out and be retried.
Debug checklist
Run these in order. Most people find the problem in the first three.
- Which secret is loaded? Compare the first characters of
STRIPE_WEBHOOK_SECRETin the running environment with the source: thestripe listenoutput locally, or the Dashboard endpoint in production. - Is it the right mode? Test-mode events need the test secret; live events need the live secret.
- Is it the right endpoint? Check the delivery's endpoint URL in Workbench matches the app you're debugging.
- Is the body raw? Search the route for
req.json(),JSON.stringifyor any middleware that touches the body. - Is the header there? Log whether
stripe-signatureis present and starts witht=. - Which runtime? Confirm
export const runtime = "nodejs", or useconstructEventAsyncon Edge. - Is the clock right? Compare the server time with the
t=value. - Is anything in between? Proxies, tunnels or redirects (Stripe treats a
3xxas a failed delivery).
FAQ
Why does it work with stripe listen but not in production?
Almost always the secret. The CLI prints its own secret, which only works for events it forwards. Production needs the secret of the Dashboard endpoint, set in your production environment variables.
Can I verify Stripe webhooks on the Edge runtime?
Yes, with constructEventAsync() and Stripe.createSubtleCryptoProvider(). Pinning the route to the Node runtime is simpler and avoids the problem.
Do I need a different secret per environment?
Each endpoint and mode has its own secret, so production, staging and local CLI forwarding will each have one. Store them per environment.
Is it safe to log the raw body?
Avoid it in production. Events can contain customer names, emails and addresses. Log the event id, type and the verification error instead.
Check the rest of your Stripe setup
Disclosure: Launch Audit and AI Rules are our own paid products. Launch Audit ($12; UK buyers pay $14.40 incl. VAT) runs 28 checks across a Next.js, Supabase and Stripe codebase. Its Stripe checks include a webhook with no constructEvent call, webhooks with no event-id idempotency, and a webhook route on the Edge runtime. It also flags webhook secrets exposed through NEXT_PUBLIC_ variables, and its manual checklist reminds you to keep your live webhook endpoint and secret separate from test. AI Rules ($19; UK buyers pay $22.80 incl. VAT) gives your coding agent rules for this stack, including one that tells it to read the raw body before verifying Stripe webhooks. They reduce risk; they don't replace code review.
Catch this in CI. Add our free checker (ours, MIT) to your PR workflow:
- uses: edtrackai/nextjs-supabase-stripe-security-checker@v0
with:
fail-on: high
It runs 24 checks on every pull request and keeps one PR comment with the findings. Full workflow: docs/github-action.md