Stripe webhook signature verification failed: 3 things to check
This article was created with the help of AI. Each step was checked against the Stripe docs and the stripe-node library listed at the end.
Here's why this error matters. A customer pays, Stripe sends your app a webhook, and your app turns it away because the signature check fails. The customer paid and gets nothing. Stripe keeps retrying for a while, but you may not notice until someone writes in, upset. The good news: the cause is almost always one of three small things.
The error:
Webhook signature verification failed.
No signatures found matching the expected signature for payload.
Short answer: Stripe's check uses three things: the raw request body, the Stripe-Signature header and your endpoint's signing secret. If any one of them is wrong, the check fails. Stripe says the most common cause is the wrong secret.
Check 1: Is it the right secret?
- The secret starts with whsec_. It is not your API key (sk_...) and not the endpoint ID (we_...).
- The secret from stripe listen on your laptop is different from the secret of an endpoint you made in the Stripe Dashboard. Don't mix them.
- Test mode and live mode use different secrets. When you go live, copy the live endpoint's secret.
- To find it: in Stripe, open the endpoint and click Reveal secret.
- Look for a space or line break at the end of the value in your environment variables.
- Changed the value on your host? Deploy again. Many hosts only load new values on a new deploy.
Check 2: Is the body untouched?
Stripe signs the exact bytes it sends. If your app turns the body into JSON and back, the bytes change and the check fails. Get the raw body.
Next.js (App Router):
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const body = await req.text() // raw text. Do not use req.json()
const sig = req.headers.get('stripe-signature')
if (!sig) return new Response('Missing signature', { status: 400 })
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
} catch (err) {
return new Response(`Webhook Error: ${(err as Error).message}`, { status: 400 })
}
// handle event.type here
return Response.json({ received: true })
}
Express: put the raw parser on the webhook route, and add express.json() after it.
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
let event
try {
event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], endpointSecret)
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`)
}
// handle event.type here
res.json({ received: true })
})
app.use(express.json()) // other routes only
Next.js (Pages Router): turn off the body parser for this route and read the raw bytes.
// pages/api/webhook.ts
import type { NextApiRequest, NextApiResponse } from 'next'
import Stripe from 'stripe'
export const config = { api: { bodyParser: false } }
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
async function rawBody(req: NextApiRequest) {
const chunks: Buffer[] = []
for await (const chunk of req) chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk)
return Buffer.concat(chunks)
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') return res.status(405).end()
const sig = req.headers['stripe-signature'] as string
try {
const event = stripe.webhooks.constructEvent(await rawBody(req), sig, process.env.STRIPE_WEBHOOK_SECRET!)
// handle event.type here
return res.json({ received: true })
} catch (err) {
return res.status(400).send(`Webhook Error: ${(err as Error).message}`)
}
}
Supabase Edge Functions (Deno): use await req.text(), and use the async version with Stripe's crypto provider:
const cryptoProvider = Stripe.createSubtleCryptoProvider()
const event = await stripe.webhooks.constructEventAsync(body, signature, secret, undefined, cryptoProvider)
The plain constructEvent fails there with "SubtleCryptoProvider cannot be used in a synchronous context". Supabase also blocks Stripe with a 401 by default until you set verify_jwt = false for the webhook function.
Something in front of your app can also change the body, such as a proxy or a forwarding tool. Even a small change, like extra spaces in the JSON, breaks the check.
Check 3: Is the header right?
Print the Stripe-Signature header once. It should look like t=...,v1=.... If it is empty, your code is reading the wrong header, or something in between removed it.
Tip: a "too old" error
Stripe's libraries also reject events whose timestamp is more than 5 minutes off from your server's clock (the default). If you replay a saved request later, it will fail. Send a fresh test event instead, and make sure your server's clock is correct.
Make sure fake signatures are refused
Once real events pass, check the other side: your endpoint must say no to a bad signature. Try it on your own endpoint:
curl -i -X POST https://yourapp.com/api/webhooks/stripe \
-H 'Content-Type: application/json' \
-H 'Stripe-Signature: t=1,v1=bad' \
-d '{}'
You want a 4xx, such as 400. If you get a 2xx, your route is not checking the signature.
Sources
- Stripe, Resolve webhook signature verification errors: <docs.stripe.com>
- Stripe, Webhooks: <docs.stripe.com>
- Stripe Node library (constructEvent, constructEventAsync): <stripe/stripe-node>
- Supabase example code (Deno): <github.com/supabase/supabase/blob/master/examples…>
---
More plain-English webhook fix guides: tillping.online
Tillping is a small tool that checks the payment-webhook path of your app, so a paying customer isn't left with nothing while you hear nothing. It isn't open yet. If you'd like a heads-up when it is, join the early-bird list: tillping.online
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.
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, first published on our site, 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). We aren'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:
1. The raw body: the exact UTF-8 string Stripe sent, byte for byte.
2. The signature: the Stripe-Signature header value, unchanged.
3. 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 listen prints its own whsec_ 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.
1. Which secret is loaded? Compare the first characters of STRIPE_WEBHOOK_SECRET in the running environment with the source: the stripe listen output locally, or the Dashboard endpoint in production.
2. Is it the right mode? Test-mode events need the test secret; live events need the live secret.
3. Is it the right endpoint? Check the delivery's endpoint URL in Workbench matches the app you're debugging.
4. Is the body raw? Search the route for req.json(), JSON.stringify or any middleware that touches the body.
5. Is the header there? Log whether stripe-signature is present and starts with t=.
6. Which runtime? Confirm export const runtime = "nodejs", or use constructEventAsync on Edge.
7. Is the clock right? Compare the server time with the t= value.
8. Is anything in between? Proxies, tunnels or redirects (Stripe treats a 3xx as 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 plus VAT where applicable) 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 plus VAT where applicable) 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
We aren't affiliated with or endorsed by Stripe, Supabase or Vercel; Stripe, Supabase and Next.js are their owners' trademarks, and other product names belong to their owners; all are used here only to name those products.
Sources (checked 14 October 2026):
- Stripe: Resolve webhook signature verification errors
- Stripe: Receive Stripe events in your webhook endpoint
- Next.js: route.js (Route Handlers)
- stripe-node: Webhooks.ts
---
More fixes on the Stack & Ship blog. Estimate your AI API bill with our free AI API cost calculator (estimates only).
3 comments
Stripe webhook returns 401 on a Supabase Edge Function? Here's the fix (and the step after it)
---
title: "Stripe webhook returns 401 on a Supabase Edge Function? Here's the fix (and the step after it)"
published: true
description: "Supabase Edge Functions check for a JWT by default and Stripe can't send one. Turn that off for the webhook function only, then verify the Stripe signature with constructEventAsync on the raw body."
tags: stripe, supabase, webhooks, deno
cover_image: webhookproof.com
---
Checkout works. The customer pays. Stripe's delivery log for your webhook shows 401 on every attempt, and your Edge Function logs show nothing at all.
That combination is almost always Supabase's built-in JWT check, not your Stripe code. This post covers why it happens, how to turn it off for the webhook function only, and the step you must take right after, because once the function is public, the Stripe signature is the only thing protecting it. Every claim links to the doc it comes from (see Sources at the end).
What the 401 looks like
Open the endpoint in the Stripe Dashboard and look at a failed delivery. If the response body is one of these, the request never reached your handler:
{ "code": 401, "message": "Missing authorization header" }
{ "code": 401, "message": "Invalid Token or Protected Header formatting" }
Supabase's troubleshooting guide says both messages "come from the legacy auth verification check", which "runs before your code. When it fails, your function never executes." If the body is a message you wrote yourself (or empty), your own code returned the 401 and you should look there instead.
Stripe's side agrees: its webhook docs list 4xx responses, including 401 and 403 "when the destination URL has access restrictions", as failed deliveries, and ask that your endpoint be "publicly accessible to the internet".
Why it happens
Supabase's function configuration docs: "By default, Edge Functions require a valid JWT in the authorization header." Stripe sends a Stripe-Signature header and a JSON body. It has no Supabase user token, so the platform rejects it with a 401 before Deno.serve (or your exported fetch) ever runs.
Supabase calls this out directly: "Stripe webhooks need to be publicly accessible (Stripe doesn't have your user tokens)."
Fix 1: turn off JWT verification for the webhook function only
In config.toml (sticks across deploys)
This is the exact example in Supabase's function configuration docs:
# supabase/config.toml
# Disables authentication for the Stripe webhook.
[functions.stripe-webhook]
verify_jwt = false
The key after functions. is your function's name. Leave every other function on the default.
On the CLI
The supabase functions deploy reference lists --no-verify-jwt ("Disable JWT verification for the Function"), and Supabase's own Stripe example deploys with it:
supabase functions deploy stripe-webhook --no-verify-jwt
For local testing, the same flag works on serve, and the example pairs it with stripe listen:
# terminal 1
supabase functions serve --no-verify-jwt --env-file ./supabase/.env.local
# terminal 2
stripe listen --forward-to localhost:54321/functions/v1/stripe-webhook
In the Dashboard
Supabase's 401 troubleshooting guide also lists a Dashboard route: open the function's detail tab and toggle off JWT verification. If your function gets redeployed by a tool (an AI app builder, a CI job), put the setting in config.toml as well so a redeploy doesn't quietly turn it back on. Users have reported exactly that in a Supabase CLI issue (linked below), where "Enforce JWT verification" was enabled again after a push.
Fix 2: verify the Stripe signature, the Deno way
Supabase's docs carry a warning with the flag: it "will allow anyone to invoke your Edge Function without a valid JWT." Your webhook URL is now public. If the handler trusts the JSON body, anyone who knows the URL can POST a hand-written checkout.session.completed and unlock your product.
Supabase's "Securing Edge Functions" page says the same for external webhooks: providers like Stripe "sign the request body with their own shared secret", so skip the credential check, "then verify the provider's signature inside the handler. Keep verify_jwt = false."
Two details matter in Deno:
1. Use the raw body. Stripe: "Stripe requires the raw body of the request to perform signature verification... Any manipulation to the raw body of the request causes the verification to fail." Read it with await req.text(). Don't call req.json() first.
2. Use the async verifier with a Web Crypto provider. stripe-node's SubtleCryptoProvider "only supports asynchronous operations". Its synchronous path throws SubtleCryptoProvider cannot be used in a synchronous context. So use constructEventAsync with Stripe.createSubtleCryptoProvider(), not constructEvent.
This is the shape of Supabase's official Stripe webhook example:
// supabase/functions/stripe-webhook/index.ts
import Stripe from 'npm:stripe@^22'
import { withSupabase } from 'npm:@supabase/server@^1'
const stripe = new Stripe(Deno.env.get('STRIPE_API_KEY') as string)
// Needed to use the Web Crypto API in Deno
const cryptoProvider = Stripe.createSubtleCryptoProvider()
// Stripe verifies the request via its signature, so deploy with verify_jwt = false.
export default {
fetch: withSupabase({ auth: 'none' }, async (req) => {
const signature = req.headers.get('Stripe-Signature')
const body = await req.text() // raw body, not req.json()
let event
try {
event = await stripe.webhooks.constructEventAsync(
body,
signature!,
Deno.env.get('STRIPE_WEBHOOK_SIGNING_SECRET')!,
undefined,
cryptoProvider,
)
} catch (err) {
return new Response((err as Error).message, { status: 400 })
}
// handle event.type here (e.g. checkout.session.completed)
return Response.json({ ok: true })
}),
}
If you're on a plain Deno.serve(async (req) => { ... }) handler instead of the withSupabase wrapper, the three lines that matter are the same: await req.text(), constructEventAsync(...) with cryptoProvider, and a 400 when it throws.
Fix 3: the right signing secret for the right endpoint
A 401 that turns into a 400 after Fix 1 usually means your code is now running but the signature doesn't match. Check which whsec_ value the function has.
From Stripe's webhook docs: "Stripe generates a unique secret key for each endpoint. If you use the same endpoint for both test and live API keys, the secret is different for each one. Additionally, if you use multiple endpoints, you must obtain a secret for each one." The stripe listen command prints its own signing secret too ("Your webhook signing secret is ..."), which is for local forwarding, not your deployed endpoint.
Set the secret for the deployed function with the Supabase CLI (or under Edge Function Secrets in the Dashboard):
supabase secrets set STRIPE_WEBHOOK_SIGNING_SECRET=whsec_...
Supabase's secrets docs note that functions "read a new secret immediately, so you don't need to redeploy." When you go live, swap in the live endpoint's secret. The test one won't verify live events.
How to check
Two curl calls tell you most of what you need. Replace the URL with your function's (https://<project-ref>.supabase.co/functions/v1/<name>).
# 1) Unsigned POST, no Authorization header (this is what Stripe "looks like" to the platform)
curl -i -X POST -H 'content-type: application/json' -d '{}' \
https://<project-ref>.supabase.co/functions/v1/stripe-webhook
- 401 with "Missing authorization header": JWT verification is still on. Go back to Fix 1.
- 400 (with the official example, stripe-node's message is No stripe-signature header value was provided.): good. The function is reachable and rejects unsigned events.
- 200: the function is reachable but accepts unsigned events. Go to Fix 2 now.
Then, in the Stripe Dashboard, resend a failed event and confirm the delivery shows a 2xx. Stripe's docs say Dashboard resends work "for up to 15 days after the event creation", and in live mode Stripe itself keeps retrying for up to three days.
If you'd rather not run the curls by hand, WebhookProof's free checker runs the reachability and unsigned-event checks against your URL with 3 harmless requests: Stripe webhooks on Supabase Edge Functions: 401s and signature errors.
Checklist
- [ ] verify_jwt = false for the webhook function only, in supabase/config.toml (and/or deployed with --no-verify-jwt)
- [ ] Handler reads await req.text() before anything else touches the body
- [ ] Verification uses constructEventAsync + Stripe.createSubtleCryptoProvider() and returns 400 on failure
- [ ] STRIPE_WEBHOOK_SIGNING_SECRET is the whsec_ for this endpoint in this mode (not the stripe listen one in production)
- [ ] An unsigned curl gets 400, and a resent Dashboard event gets 2xx
WebhookProof is an independent tool, not affiliated with Stripe, Inc. or Supabase, Inc. Quotes are from the linked documentation as read on 2026-10-18.
Sources
- Supabase, Function Configuration (default JWT requirement, verify_jwt = false example for stripe-webhook, --no-verify-jwt caution): supabase.com
- Supabase, Edge Function 401 error response (error bodies, "runs before your code", Dashboard / CLI / Management API fixes): supabase.com
- Supabase, Securing Edge Functions (external webhooks: auth: 'none', verify signature, keep verify_jwt = false): supabase.com
- Supabase, Handling Stripe Webhooks example: supabase.com
- Supabase example source (constructEventAsync, createSubtleCryptoProvider, req.text()): github.com/supabase/supabase/blob/master/examples…
- Supabase example README (serve --no-verify-jwt, deploy --no-verify-jwt, stripe listen): github.com/supabase/supabase/blob/master/examples…
- Supabase CLI reference, supabase functions deploy (--no-verify-jwt flag): supabase.com
- Supabase, Environment variables / secrets (supabase secrets set, no redeploy needed): supabase.com
- Supabase CLI issue #3397 (users reporting verify_jwt = false not applied / JWT enforcement re-enabled after a push): cli#3397
- Stripe, Receive Stripe events in your webhook endpoint (raw body, per-endpoint and per-mode secrets, 4xx delivery errors, retries, resend window): docs.stripe.com
- stripe-node, SubtleCryptoProvider source (async-only): github.com/stripe/stripe-node/blob/master/src/cry…
- stripe-node, Webhooks.ts source (error messages): github.com/stripe/stripe-node/blob/master/src/Web…
1 reaction · 2 comments
Mentions
Stripe webhook signature verification failed: 3 things to check This article was created with the help of AI. Each step was checked against the Stripe docs and the stripe-node library listed at the end. Here's why this error matters. A customer pays, Stripe sends your app a webhook, and your app turns it away because the signature check fails. The customer paid and gets nothing. Stripe keeps retrying for a while, but you may not notice until someone writes in, upset. The good news: the cause is almost always one of three small things. The error: Webhook signature verification failed. No signatures found matching the expected signature for payload. Short answer: Stripe's check uses three things: the raw request body, the Stripe-Signature header and your endpoint's signing secret. If any one of them is wrong, the check fails. Stripe says the most common cause is the wrong secret. Check 1: Is it the right secret? - The secret starts with whsec_. It is not your API key (sk_...) and not the endpoint ID (we_...). - The secret from stripe listen on your laptop is different from the secret of an endpoint you made in the Stripe Dashboard. Don't mix them. - Test mode and live mode use different secrets. When you go live, copy the live endpoint's secret. - To find it: in Stripe, open the endpoint and click Reveal secret. - Look for a space or line break at the end of the value in your environment variables. - Changed the value on your host? Deploy again. Many hosts only load new values on a new deploy. Check 2: Is the body untouched? Stripe signs the exact bytes it sends. If your app turns the body into JSON and back, the bytes change and the check fails. Get the raw body. Next.js (App Router): // app/api/webhooks/stripe/route.ts import Stripe from 'stripe' const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!) export async function POST(req: Request) { const body = await req.text() // raw text. Do not use req.json() const sig = req.headers.get('stripe-signature') if (!sig) return new Response('Missing signature', { status: 400 }) let event: Stripe.Event try { event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!) } catch (err) { return new Response(`Webhook Error: ${(err as Error).message}`, { status: 400 }) } // handle event.type here return Response.json({ received: true }) } Express: put the raw parser on the webhook route, and add express.json() after it. app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => { let event try { event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], endpointSecret) } catch (err) { return res.status(400).send(`Webhook Error: ${err.message}`) } // handle event.type here res.json({ received: true }) }) app.use(express.json()) // other routes only Next.js (Pages Router): turn off the body parser for this route and read the raw bytes. // pages/api/webhook.ts import type { NextApiRequest, NextApiResponse } from 'next' import Stripe from 'stripe' export const config = { api: { bodyParser: false } } const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!) async function rawBody(req: NextApiRequest) { const chunks: Buffer[] = [] for await (const chunk of req) chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk) return Buffer.concat(chunks) } export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== 'POST') return res.status(405).end() const sig = req.headers['stripe-signature'] as string try { const event = stripe.webhooks.constructEvent(await rawBody(req), sig, process.env.STRIPE_WEBHOOK_SECRET!) // handle event.type here return res.json({ received: true }) } catch (err) { return res.status(400).send(`Webhook Error: ${(err as Error).message}`) } } Supabase Edge Functions (Deno): use await req.text(), and use the async version with Stripe's crypto provider: const cryptoProvider = Stripe.createSubtleCryptoProvider() const event = await stripe.webhooks.constructEventAsync(body, signature, secret, undefined, cryptoProvider) The plain constructEvent fails there with "SubtleCryptoProvider cannot be used in a synchronous context". Supabase also blocks Stripe with a 401 by default until you set verify_jwt = false for the webhook function. Something in front of your app can also change the body, such as a proxy or a forwarding tool. Even a small change, like extra spaces in the JSON, breaks the check. Check 3: Is the header right? Print the Stripe-Signature header once. It should look like t=...,v1=.... If it is empty, your code is reading the wrong header, or something in between removed it. Tip: a "too old" error Stripe's libraries also reject events whose timestamp is more than 5 minutes off from your server's clock (the default). If you replay a saved request later, it will fail. Send a fresh test event instead, and make sure your server's clock is correct. Make sure fake signatures are refused Once real events pass, check the other side: your endpoint must say no to a bad signature. Try it on your own endpoint: curl -i -X POST https://yourapp.com/api/webhooks/stripe \ -H 'Content-Type: application/json' \ -H 'Stripe-Signature: t=1,v1=bad' \ -d '{}' You want a 4xx, such as 400. If you get a 2xx, your route is not checking the signature. Sources - Stripe, Resolve webhook signature verification errors: <docs.stripe.com> - Stripe, Webhooks: <docs.stripe.com> - Stripe Node library (constructEvent, constructEventAsync): <stripe/stripe-node> - Supabase example code (Deno): <github.com/supabase/supabase/blob/master/examples…> --- More plain-English webhook fix guides: tillping.online Tillping is a small tool that checks the payment-webhook path of your app, so a paying customer isn't left with nothing while you hear nothing. It isn't open yet. If you'd like a heads-up when it is, join the early-bird list: tillping.online
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. 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, first published on our site, 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). We aren'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: 1. The raw body: the exact UTF-8 string Stripe sent, byte for byte. 2. The signature: the Stripe-Signature header value, unchanged. 3. 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 listen prints its own whsec_ 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. 1. Which secret is loaded? Compare the first characters of STRIPE_WEBHOOK_SECRET in the running environment with the source: the stripe listen output locally, or the Dashboard endpoint in production. 2. Is it the right mode? Test-mode events need the test secret; live events need the live secret. 3. Is it the right endpoint? Check the delivery's endpoint URL in Workbench matches the app you're debugging. 4. Is the body raw? Search the route for req.json(), JSON.stringify or any middleware that touches the body. 5. Is the header there? Log whether stripe-signature is present and starts with t=. 6. Which runtime? Confirm export const runtime = "nodejs", or use constructEventAsync on Edge. 7. Is the clock right? Compare the server time with the t= value. 8. Is anything in between? Proxies, tunnels or redirects (Stripe treats a 3xx as 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 plus VAT where applicable) 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 plus VAT where applicable) 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 We aren't affiliated with or endorsed by Stripe, Supabase or Vercel; Stripe, Supabase and Next.js are their owners' trademarks, and other product names belong to their owners; all are used here only to name those products. Sources (checked 14 October 2026): - Stripe: Resolve webhook signature verification errors - Stripe: Receive Stripe events in your webhook endpoint - Next.js: route.js (Route Handlers) - stripe-node: Webhooks.ts --- More fixes on the Stack & Ship blog. Estimate your AI API bill with our free AI API cost calculator (estimates only).
Stripe webhook returns 401 on a Supabase Edge Function? Here's the fix (and the step after it) --- title: "Stripe webhook returns 401 on a Supabase Edge Function? Here's the fix (and the step after it)" published: true description: "Supabase Edge Functions check for a JWT by default and Stripe can't send one. Turn that off for the webhook function only, then verify the Stripe signature with constructEventAsync on the raw body." tags: stripe, supabase, webhooks, deno cover_image: webhookproof.com --- Checkout works. The customer pays. Stripe's delivery log for your webhook shows 401 on every attempt, and your Edge Function logs show nothing at all. That combination is almost always Supabase's built-in JWT check, not your Stripe code. This post covers why it happens, how to turn it off for the webhook function only, and the step you must take right after, because once the function is public, the Stripe signature is the only thing protecting it. Every claim links to the doc it comes from (see Sources at the end). What the 401 looks like Open the endpoint in the Stripe Dashboard and look at a failed delivery. If the response body is one of these, the request never reached your handler: { "code": 401, "message": "Missing authorization header" } { "code": 401, "message": "Invalid Token or Protected Header formatting" } Supabase's troubleshooting guide says both messages "come from the legacy auth verification check", which "runs before your code. When it fails, your function never executes." If the body is a message you wrote yourself (or empty), your own code returned the 401 and you should look there instead. Stripe's side agrees: its webhook docs list 4xx responses, including 401 and 403 "when the destination URL has access restrictions", as failed deliveries, and ask that your endpoint be "publicly accessible to the internet". Why it happens Supabase's function configuration docs: "By default, Edge Functions require a valid JWT in the authorization header." Stripe sends a Stripe-Signature header and a JSON body. It has no Supabase user token, so the platform rejects it with a 401 before Deno.serve (or your exported fetch) ever runs. Supabase calls this out directly: "Stripe webhooks need to be publicly accessible (Stripe doesn't have your user tokens)." Fix 1: turn off JWT verification for the webhook function only In config.toml (sticks across deploys) This is the exact example in Supabase's function configuration docs: # supabase/config.toml # Disables authentication for the Stripe webhook. [functions.stripe-webhook] verify_jwt = false The key after functions. is your function's name. Leave every other function on the default. On the CLI The supabase functions deploy reference lists --no-verify-jwt ("Disable JWT verification for the Function"), and Supabase's own Stripe example deploys with it: supabase functions deploy stripe-webhook --no-verify-jwt For local testing, the same flag works on serve, and the example pairs it with stripe listen: # terminal 1 supabase functions serve --no-verify-jwt --env-file ./supabase/.env.local # terminal 2 stripe listen --forward-to localhost:54321/functions/v1/stripe-webhook In the Dashboard Supabase's 401 troubleshooting guide also lists a Dashboard route: open the function's detail tab and toggle off JWT verification. If your function gets redeployed by a tool (an AI app builder, a CI job), put the setting in config.toml as well so a redeploy doesn't quietly turn it back on. Users have reported exactly that in a Supabase CLI issue (linked below), where "Enforce JWT verification" was enabled again after a push. Fix 2: verify the Stripe signature, the Deno way Supabase's docs carry a warning with the flag: it "will allow anyone to invoke your Edge Function without a valid JWT." Your webhook URL is now public. If the handler trusts the JSON body, anyone who knows the URL can POST a hand-written checkout.session.completed and unlock your product. Supabase's "Securing Edge Functions" page says the same for external webhooks: providers like Stripe "sign the request body with their own shared secret", so skip the credential check, "then verify the provider's signature inside the handler. Keep verify_jwt = false." Two details matter in Deno: 1. Use the raw body. Stripe: "Stripe requires the raw body of the request to perform signature verification... Any manipulation to the raw body of the request causes the verification to fail." Read it with await req.text(). Don't call req.json() first. 2. Use the async verifier with a Web Crypto provider. stripe-node's SubtleCryptoProvider "only supports asynchronous operations". Its synchronous path throws SubtleCryptoProvider cannot be used in a synchronous context. So use constructEventAsync with Stripe.createSubtleCryptoProvider(), not constructEvent. This is the shape of Supabase's official Stripe webhook example: // supabase/functions/stripe-webhook/index.ts import Stripe from 'npm:stripe@^22' import { withSupabase } from 'npm:@supabase/server@^1' const stripe = new Stripe(Deno.env.get('STRIPE_API_KEY') as string) // Needed to use the Web Crypto API in Deno const cryptoProvider = Stripe.createSubtleCryptoProvider() // Stripe verifies the request via its signature, so deploy with verify_jwt = false. export default { fetch: withSupabase({ auth: 'none' }, async (req) => { const signature = req.headers.get('Stripe-Signature') const body = await req.text() // raw body, not req.json() let event try { event = await stripe.webhooks.constructEventAsync( body, signature!, Deno.env.get('STRIPE_WEBHOOK_SIGNING_SECRET')!, undefined, cryptoProvider, ) } catch (err) { return new Response((err as Error).message, { status: 400 }) } // handle event.type here (e.g. checkout.session.completed) return Response.json({ ok: true }) }), } If you're on a plain Deno.serve(async (req) => { ... }) handler instead of the withSupabase wrapper, the three lines that matter are the same: await req.text(), constructEventAsync(...) with cryptoProvider, and a 400 when it throws. Fix 3: the right signing secret for the right endpoint A 401 that turns into a 400 after Fix 1 usually means your code is now running but the signature doesn't match. Check which whsec_ value the function has. From Stripe's webhook docs: "Stripe generates a unique secret key for each endpoint. If you use the same endpoint for both test and live API keys, the secret is different for each one. Additionally, if you use multiple endpoints, you must obtain a secret for each one." The stripe listen command prints its own signing secret too ("Your webhook signing secret is ..."), which is for local forwarding, not your deployed endpoint. Set the secret for the deployed function with the Supabase CLI (or under Edge Function Secrets in the Dashboard): supabase secrets set STRIPE_WEBHOOK_SIGNING_SECRET=whsec_... Supabase's secrets docs note that functions "read a new secret immediately, so you don't need to redeploy." When you go live, swap in the live endpoint's secret. The test one won't verify live events. How to check Two curl calls tell you most of what you need. Replace the URL with your function's (https://<project-ref>.supabase.co/functions/v1/<name>). # 1) Unsigned POST, no Authorization header (this is what Stripe "looks like" to the platform) curl -i -X POST -H 'content-type: application/json' -d '{}' \ https://<project-ref>.supabase.co/functions/v1/stripe-webhook - 401 with "Missing authorization header": JWT verification is still on. Go back to Fix 1. - 400 (with the official example, stripe-node's message is No stripe-signature header value was provided.): good. The function is reachable and rejects unsigned events. - 200: the function is reachable but accepts unsigned events. Go to Fix 2 now. Then, in the Stripe Dashboard, resend a failed event and confirm the delivery shows a 2xx. Stripe's docs say Dashboard resends work "for up to 15 days after the event creation", and in live mode Stripe itself keeps retrying for up to three days. If you'd rather not run the curls by hand, WebhookProof's free checker runs the reachability and unsigned-event checks against your URL with 3 harmless requests: Stripe webhooks on Supabase Edge Functions: 401s and signature errors. Checklist - [ ] verify_jwt = false for the webhook function only, in supabase/config.toml (and/or deployed with --no-verify-jwt) - [ ] Handler reads await req.text() before anything else touches the body - [ ] Verification uses constructEventAsync + Stripe.createSubtleCryptoProvider() and returns 400 on failure - [ ] STRIPE_WEBHOOK_SIGNING_SECRET is the whsec_ for this endpoint in this mode (not the stripe listen one in production) - [ ] An unsigned curl gets 400, and a resent Dashboard event gets 2xx WebhookProof is an independent tool, not affiliated with Stripe, Inc. or Supabase, Inc. Quotes are from the linked documentation as read on 2026-10-18. Sources - Supabase, Function Configuration (default JWT requirement, verify_jwt = false example for stripe-webhook, --no-verify-jwt caution): supabase.com - Supabase, Edge Function 401 error response (error bodies, "runs before your code", Dashboard / CLI / Management API fixes): supabase.com - Supabase, Securing Edge Functions (external webhooks: auth: 'none', verify signature, keep verify_jwt = false): supabase.com - Supabase, Handling Stripe Webhooks example: supabase.com - Supabase example source (constructEventAsync, createSubtleCryptoProvider, req.text()): github.com/supabase/supabase/blob/master/examples… - Supabase example README (serve --no-verify-jwt, deploy --no-verify-jwt, stripe listen): github.com/supabase/supabase/blob/master/examples… - Supabase CLI reference, supabase functions deploy (--no-verify-jwt flag): supabase.com - Supabase, Environment variables / secrets (supabase secrets set, no redeploy needed): supabase.com - Supabase CLI issue #3397 (users reporting verify_jwt = false not applied / JWT enforcement re-enabled after a push): cli#3397 - Stripe, Receive Stripe events in your webhook endpoint (raw body, per-endpoint and per-mode secrets, 4xx delivery errors, retries, resend window): docs.stripe.com - stripe-node, SubtleCryptoProvider source (async-only): github.com/stripe/stripe-node/blob/master/src/cry… - stripe-node, Webhooks.ts source (error messages): github.com/stripe/stripe-node/blob/master/src/Web…