Fix: Stripe Webhook Signature Verification Failed
TL;DR — If Stripe throws Webhook signature verification failed or No signatures found matching the expected signature for payload, the number-one cause in the App Router is that your handler read the body as JSON (or re-stringified it) before verifying. Stripe signs the raw request bytes, so you must pass the untouched body to constructEvent. In a route handler that means const body = await req.text() — never await req.json() — then stripe.webhooks.constructEvent(body, signature, secret).
Why does Stripe say "Webhook signature verification failed"?
Because the bytes you handed constructEvent are not the bytes Stripe hashed.
Here is the mechanism. Stripe computes an HMAC-SHA256 over the exact string ${timestamp}.${raw_body}, keyed with your endpoint signing secret (whsec_…). It ships the result in the Stripe-Signature header, which looks like t=1699999999,v1=abc123…. When you call constructEvent(payload, header, secret), the SDK recomputes that HMAC from the payload you pass it plus the t= timestamp, then does a constant-time compare against the v1 value. If your payload differs from Stripe's original by even one character — a re-ordered key, a stray space, a different unicode escape — the HMAC differs and it throws. The No signatures found matching the expected signature message means exactly that: nothing in the header matched what the SDK computed from your payload.
So the fix is almost always about preserving the raw body.
The #1 cause: the body was parsed before verification
If you do JSON.stringify(await req.json()), the runtime re-emits the object with its own key ordering and spacing. That reconstructed string is essentially never byte-identical to what Stripe sent, so verification fails every time. Stripe's docs are explicit that the payload must be "the body string that Stripe sends in UTF-8 encoding without any changes."
In the App Router, route handlers receive a standard Web Request and there is no automatic body parser running ahead of you. You just read the raw body and pass it straight through. This is the correct, minimal implementation — and it is exactly what Stripe's official Next.js example does:
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { headers } from 'next/headers';
import Stripe from 'stripe';
// Node runtime is the route-handler default; constructEvent needs Node crypto.
export const runtime = 'nodejs';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: NextRequest) {
// RAW body — one read, unmodified UTF-8 bytes. Never req.json() first.
const body = await req.text();
const signature = (await headers()).get('stripe-signature');
if (!signature) {
return NextResponse.json(
{ error: 'Missing stripe-signature header' },
{ status: 400 },
);
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
console.error(`Webhook signature verification failed: ${message}`);
return NextResponse.json({ error: `Webhook Error: ${message}` }, { status: 400 });
}
// Only now is the payload trustworthy.
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
// fulfill order / grant entitlement
break;
}
default:
break;
}
// Return 2xx fast; push slow work to a queue.
return NextResponse.json({ received: true }, { status: 200 });
}
A Request body is a one-shot stream: read it once as JSON and it's consumed, re-serialize it and the bytes change. req.text() gives you the original payload. (await req.arrayBuffer() → Buffer.from(...) is equivalent — constructEvent accepts a string or Buffer.)
If you're migrating from the Pages Router, note the contrast: pages/api/* ran a JSON bodyParser by default, so you had to export const config = { api: { bodyParser: false } } and buffer the stream with micro or a raw-body helper. The App Router needs none of that machinery — await req.text() is the whole story. While you're auditing routes, it's worth running through a broader Next.js App Router security checklist so the webhook isn't the only unguarded entry point.
Why does it fail even when my body is raw?
Four more causes, all rooted in the same HMAC mechanism.
Wrong or mismatched signing secret. The secret is the HMAC key, so a correct body with the wrong key still throws. The common traps: test-mode and live-mode endpoints have different secrets; stripe listen prints its own whsec_… that is not the same as a Dashboard endpoint's secret (Stripe warns directly against verifying CLI-forwarded events with a Dashboard secret, or vice versa); and process.env.STRIPE_WEBHOOK_SECRET may be undefined, from the wrong environment, or accidentally set to your sk_… API key. Confirm the value starts with whsec_, and when debugging, log its first few characters to prove which endpoint it belongs to.
Wrong signature header. The second argument must be the Stripe-Signature header verbatim. Read exactly stripe-signature, guard for null, and pass the whole string — don't trim, split, or reconstruct it.
The body was mangled in transit. Anything that rewrites or re-encodes the payload between Stripe and constructEvent breaks the HMAC: a middleware.ts that reads the webhook path's body, or a proxy/gateway that pretty-prints or re-encodes it. Exclude the route from body-touching middleware via the matcher. There's also a runtime trap — if the route runs on the Edge runtime, synchronous constructEvent can't run because it needs Node's crypto while edge only offers async Web Crypto. Switch to the async API:
export const runtime = 'edge';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get('stripe-signature')!;
const event = await stripe.webhooks.constructEventAsync(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
// ...
return new Response(JSON.stringify({ received: true }), { status: 200 });
}
constructEventAsync also works on the Node runtime, so it's a safe default if you're unsure where your route runs.
Timestamp tolerance / replay. The t= value is part of the signed string, and constructEvent rejects events whose timestamp is more than the tolerance (default 300 seconds) from your server clock. If everything else is right but it still throws, check for clock skew (fix with NTP) or a replayed/long-queued payload. You can set tolerance explicitly as the 4th argument, but never pass 0 — that disables the anti-replay check entirely.
The bigger problem: no verification at all
The failing-verification error is annoying, but it means your defense is working. The real danger is the route that never verifies and quietly accepts everything.
A webhook endpoint is a public, unauthenticated POST route. Its URL isn't a secret — it shows up in logs and proxies and is trivially guessable (/api/webhooks/stripe). Stripe's event shapes are fully documented. So if your handler trusts the body without checking the signature, anyone can curl it with a hand-crafted checkout.session.completed body, your switch (event.type) will match, and your fulfillment logic runs with zero payment — granting subscriptions, marking orders paid, crediting balances, on a loop. Signature verification is the only thing that authenticates a webhook as genuinely from Stripe.
This is the one part of the problem a static scanner can catch before you ship. GuardLayer's nextjs/webhook-no-signature-verification rule (a warning) flags a webhook route that reads the body and processes events but never calls stripe.webhooks.constructEvent(...) — the "no verification at all" case that's visible in the source:
- Warningapp/api/webhooks/stripe/route.ts:1
Webhook without signature verification
Verify the signature before trusting the payload — Stripe: stripe.webhooks.constructEvent(rawBody, sig, endpointSecret); svix/Clerk: new Webhook(secret).verify(...); generic HMAC: recompute with crypto.createHmac and compare using timingSafeEqual. Read the RAW body (request.text()), not the parsed JSON.
Be clear about the boundary: the scanner catches the missing check because it's statically visible in your code. It cannot see the runtime Webhook signature verification failed error from cause 1 — that's a raw-body or secret mismatch that only exists at request time, and no static tool can inspect the bytes Stripe sent. The scanner closes the "I forgot to verify" hole; the raw-body discipline above closes the "I verified wrong" hole. You need both.
Two things to get right after verification passes
Idempotency. Stripe guarantees at-least-once delivery, so the same event can arrive twice. De-duplicate on the stable event.id (evt_…) using a durable, atomic store — INSERT INTO processed_webhook_events (id) VALUES ($1) ON CONFLICT (id) DO NOTHING and check the affected-row count. An in-process Set does not work: serverless invocations don't share memory.
Return 2xx fast. Stripe requires a quick 2xx before any slow logic, or it treats the request as failed and retries — compounding duplicates. Do the minimum synchronously, enqueue heavy work (emails, provisioning), and return 200 immediately.
And keep the secret server-only: never prefix STRIPE_WEBHOOK_SECRET or STRIPE_SECRET_KEY with NEXT_PUBLIC_, which would inline them into the client bundle. Route handlers run only on the server, so plain process.env access is correct — see keeping secrets out of your Next.js bundle for how that inlining actually happens. For the webhook body itself, the same trust boundary that applies to validating API route input applies here: treat event.type as attacker-controlled until constructEvent succeeds.
FAQ
Why does Stripe say "No signatures found matching the expected signature for payload"?
The v1 signatures in the Stripe-Signature header didn't match what constructEvent computed from the payload you passed. Almost always the payload wasn't the raw body — you parsed or re-stringified it. Pass await req.text() unchanged.
Do I need bodyParser: false in the App Router?
No. That config only applied to the Pages Router, which auto-parsed the body. Route handlers don't parse anything, so await req.text() already gives you the raw bytes.
Can I use await req.json() and re-stringify it?
No. JSON.stringify re-emits the object with different key order and spacing, so the bytes no longer match what Stripe signed. Read the raw text and parse only after constructEvent returns the typed event.
Why does it work locally with the CLI but fail in production?
Different secrets. stripe listen prints its own whsec_… for CLI-forwarded events; your deployed endpoint uses the Dashboard secret. Use the matching secret for each environment, and keep test/live secrets separate too.
Why do I get "SubtleCryptoProvider cannot be used in a synchronous context"?
Your route is on the Edge runtime, where crypto is async. Use constructEventAsync instead of constructEvent, or set export const runtime = 'nodejs'.
Does GuardLayer catch the "verification failed" error?
No — that's a runtime raw-body/secret mismatch a static scanner can't see. GuardLayer's nextjs/webhook-no-signature-verification rule catches the different, more dangerous case: a webhook route that processes events with no signature check at all.
Catch this before it ships — free
GuardLayer scans every push for this and 23 other Next.js + Supabase issues, with the exact fix inline.
No signup, no card — your code is scanned in memory and never stored.