Security basics
"No signatures found matching the expected signature" in Supabase
A Stripe webhook in a Supabase Edge Function that says "No signatures found matching the expected signature" has one of two causes. Never stop checking.

In short
- "No signatures found matching the expected signature for payload" means your function checked Stripe's signature and it did not match. Either the body was changed before the check, or the signing secret is not the one Stripe signed with.
- Read the body with await req.text() and pass that string, untouched, to constructEventAsync. Then compare the whsec_ secret your function holds with the one on the exact endpoint Stripe is sending from.
- Do not make the error go away by skipping the check. The webhook has to run with verify_jwt = false, so the signature is the only thing stopping a stranger from telling your app a payment succeeded.
You connected Stripe to your app, a test payment went through, and the webhook that should mark the order as paid did nothing. Stripe's dashboard shows the delivery failing, and your Supabase Edge Function's logs say this:
No signatures found matching the expected signature for payload.
Are you passing the raw request body you received from Stripe?
The good news is in where this error comes from. It is thrown by the part of your function that checks the request really came from Stripe, which means that check exists and is doing its job. "No signatures found matching the expected signature for payload" has two causes, and neither of them is fixed by checking less. The fix that turns up first when people search it, wrapping the check so the error is ignored, is the one that lets anyone mark an order as paid.
What does "No signatures found matching the expected signature" mean?
Your function checked Stripe's signature on the request, and it did not match.
Think of every webhook Stripe sends as a letter closed with a wax seal. Stripe
presses the seal with a stamp only it and you hold: your endpoint's signing
secret, a value starting whsec_. When the letter arrives, your function
presses its own copy of the stamp against the wax. If the patterns line up, the
letter is from Stripe and nobody has touched it on the way.
The seal covers the letter exactly as it was written. Stripe's library takes the
text of the request, the time from the Stripe-Signature header and your
secret, works out what the signature should be, and looks for it in that
header. The message above, written into
stripe-node itself,
is what it throws when nothing there matches. Stripe's
signature troubleshooting page
puts it plainly: at least one of the three things you passed in is wrong.
So there are two places to look. Either the letter was changed before your function checked the seal, or your function is holding the wrong stamp.
Cause 1: the body was changed before the check
The most common version in an Edge Function is a body that was read as JSON first and turned back into text for the check.
const event = await req.json()
await stripe.webhooks.constructEventAsync(JSON.stringify(event), signature, secret, undefined, cryptoProvider)
That reads naturally and fails every time. req.json() keeps the data and
throws away the text: the spacing, the line breaks, the order Stripe wrote
things in. JSON.stringify then writes new text with the same data in it. The
seal was pressed over Stripe's text, and this is somebody else's copy of the
letter in different handwriting, so it can never match.
What Supabase's own Stripe webhook example does instead is read the body once, as text, and hand that string over untouched:
const signature = req.headers.get('Stripe-Signature')
const body = await req.text()
const event = await stripe.webhooks.constructEventAsync(
body,
signature,
Deno.env.get('STRIPE_WEBHOOK_SIGNING_SECRET'),
undefined,
cryptoProvider,
)
The comment in that example says why: the verification relies on the raw
request body rather than the parsed JSON. You do not need to parse anything
yourself afterwards, because constructEventAsync returns the event, already
parsed, once the seal checks out.
The same thing happens outside your code. Stripe's page names frameworks that parse or reformat a body before your code sees it, and stripe-node's message adds that a tool forwarding webhooks to you has to preserve the exact body, "including JSON formatting and new line style". If a proxy or a forwarding service sits between Stripe and your function, check that it passes the body on exactly as it came.
Cause 2: the signing secret is the wrong one
If the body is untouched, the stamp is wrong, and Stripe's page calls this the most common cause of all.
Three different values can end up in the place where the signing secret goes, and they look alike:
- The secret of a different endpoint. Stripe's webhooks guide says every endpoint gets its own secret, and that the same address set up for both test and live mode has a different secret in each. A live payment checked against the test endpoint's secret fails, and so does the reverse.
- The secret printed by
stripe listen. When you forward events to your machine with Stripe's command line tool, it prints awhsec_secret of its own. Stripe says it is different from the dashboard endpoint's secret and that the two get mixed up. It works for events the tool forwards and for nothing else. - Your Stripe API key.
sk_live_…orsk_test_…is the key your function uses to call Stripe. It is not a signing secret, and a check run with it can never match.
To find the right one, open the endpoint in Stripe's dashboard under
Workbench, then Webhooks, and click Reveal secret. Check the mode
you are in first. Then compare it with the one your function reads. In the
example above that is STRIPE_WEBHOOK_SIGNING_SECRET, set on the Edge
Function Secrets page of your Supabase project or with
supabase secrets set.
One more thing to look at while you are there. If the value was pasted with a space or a line break at the end, stripe-node adds a note to the same error: "The provided signing secret contains whitespace." Delete the value and paste it again with nothing after it.
The messages that look similar and mean something else
stripe-node throws several errors from the same check, and each points at a different problem. All of these are written in its webhook code:
| What your logs say | What happened |
|---|---|
No signatures found matching the expected signature for payload | The body was changed, or the secret is the wrong one. This post. |
Webhook payload must be provided as a string or a Buffer | You passed the parsed object from req.json(). Pass the text from req.text(). |
No stripe-signature header value was provided. | The request had no Stripe-Signature header. It may not have come from Stripe at all. |
Timestamp outside the tolerance zone | The seal matched, but the event is older than the limit. Stripe's libraries default to 5 minutes. |
SubtleCryptoProvider cannot be used in a synchronous context. | You called constructEvent. With the Web Crypto provider Deno needs, use constructEventAsync. |
And one that never reaches your code. If Stripe's dashboard shows the delivery failing with a 401 and your function's logs are empty, Supabase's platform turned the request away before your function ran, because of a check that has to be off for a webhook.
Why your webhook needs verify_jwt off
Because Stripe cannot prove who it is the way your app's users do, and with the check on, every delivery is refused.
Supabase puts a check called verify_jwt in front of every Edge Function, on by
default. It wants a Supabase token in the request's Authorization header, and
Stripe has none to send. Supabase's
function configuration guide
uses exactly this as its example, saying Stripe webhooks "need to be publicly
accessible", and turns the check off for that one function in
supabase/config.toml:
[functions.stripe-webhook]
verify_jwt = false
The supabase functions deploy command takes --no-verify-jwt for the same
thing. Supabase's Stripe example carries the same instruction in a comment:
Stripe verifies the request via its signature, so deploy with
verify_jwt = false.
With the platform check off, every request on the internet reaches your code, and the seal check inside your function is the only thing that tells Stripe's letters from anybody else's.
Why you should never skip the signature check
Because without it, anyone can tell your app that a payment went through.
A webhook function's address is your project's URL, which is in your frontend
for anyone to read, followed by /functions/v1/ and the function's name, which
is usually the obvious one. The configuration guide's own example calls it
stripe-webhook. With verify_jwt off, the platform lets every request
through to your code. If your code no longer checks the seal, it believes every
letter it gets.
So a stranger sends your function a request saying checkout.session.completed
with an order or a customer of their choosing in it. Your function does what it
does with a real one: marks the order paid, upgrades the account, unlocks the
download. Nobody paid. Stripe has no record of it, because Stripe never sent it.
Three versions of skipping the check all end there:
- Deleting it because it would not pass.
- Catching the error and carrying on, logging the failure and processing the event anyway. The example returns a 400 from its catch block, and that is the shape to keep: a request that fails the check stops there.
- Setting the timestamp tolerance to 0. Stripe's webhooks guide says not to: 0 turns the age check off entirely, so a real event someone captured can be sent again later.
A live Stripe secret key in your frontend is the other way payments go wrong, and it is the one we can see from outside: we found one in 3 of 30,998 scanned apps. Our free scan reads your live app's JavaScript the way a stranger would and names every key it finds, including whether it is one that belongs there. It takes about 20 seconds and needs no account: scan your app.
If a forged payment ever gets through
A check removed by a prompt does not announce itself. A builder asked to make the webhook work again is free to take the shortcut above, and your app keeps working while it does.
Reeve Monitor watches what a stranger can see from outside:
- all nine checks every hour, on up to three apps
- an email the day a re-scan grades your app lower than the one before; a secret key in your JavaScript caps the grade at D on its own
- whether the app is up, every 60 seconds
- a monthly report of what it saw
A forged event writes to your database like a real one. Reeve Care keeps a copy of your Supabase database from before it happened.
- an encrypted copy on a schedule, kept on Reeve's own storage in the EU, outside your Supabase account
- each copy verified before it counts, by counting the rows in every table
- a one-click restore that saves what is there now before it replaces anything
- your uploaded files as well, once you connect a Storage credential
- everything Monitor does
What to do now
What to do
- Open the failed delivery in Stripe's dashboard and your function's Logs tab, and read the exact message before you change anything.
- Read the body with
await req.text()and pass that string toconstructEventAsync, untouched. NeverJSON.stringifyan event to check it. - Copy the
whsec_secret from the endpoint Stripe is sending from, in the right mode, and paste it into Edge Function Secrets with nothing after it. - Keep
verify_jwt = falseon the webhook, and keep the signature check as the first thing the function does. - Return a 400 when the check fails. Never catch the error and process the event anyway.
The rest of what a function holding a secret needs is in moving a secret into an Edge Function, and if the error you have is the generic one from supabase-js, reading a non-2xx status code covers where the real message is.
FAQ
What does "No signatures found matching the expected signature for payload" mean?
Your webhook function computed the signature it expected from the request body and your signing secret, and none of the signatures in the Stripe-Signature header matched it. Stripe's library throws this when the body it was given is not byte for byte what Stripe sent, or when the secret is not the one for the endpoint that sent the event.
Why does it fail when I pass JSON.stringify of the event?
Because the signature covers the exact text Stripe sent, spaces and line breaks included. Parsing the body with req.json() and turning it back into text with JSON.stringify gives you the same data in different text, and different text has a different signature. Read the body once with await req.text() and pass that string to constructEventAsync.
Which signing secret do I use?
The whsec_ secret of the exact endpoint Stripe is delivering to. Every endpoint has its own, the same address set up in test and in live mode has a different secret in each, and the stripe listen command prints a secret of its own that only works for events it forwards. Your sk_ API key is not a signing secret at all.
Should my Stripe webhook Edge Function have verify_jwt turned off?
Yes. Stripe has no Supabase token to send, so with the check on, every delivery is refused with a 401 before your code runs. Supabase's own Stripe example says to deploy it with verify_jwt = false. That makes the signature check inside your function the only check there is, so it must never be skipped or caught and ignored.
Can I remove the signature check to get payments working?
You can, and anyone who finds the function's address can then send it an event that says a payment succeeded. The address is your project URL, which is in your frontend, plus the function's name. Without the check, your function believes every request it gets and grants whatever the event says was paid for.