Skip to content

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.

Vlad Tkachenko9 min read
A sealed envelope with a teal wax seal, and beside it a seal stamp whose pattern does not match the impression, marked in amber.

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 signature covers the text Stripe sent, not the data in it. Rewritten text with identical data fails the check.

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 a whsec_ 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_… or sk_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.

Every endpoint, every mode and the command line tool sign with their own secret. Only the one that signed this event can check 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 sayWhat happened
No signatures found matching the expected signature for payloadThe body was changed, or the secret is the wrong one. This post.
Webhook payload must be provided as a string or a BufferYou 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 zoneThe 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 to constructEventAsync, untouched. Never JSON.stringify an 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 = false on 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.

Written by

Vlad Tkachenko

Founder, Reeve

I spend my time looking at apps built with Lovable, Bolt, v0, Cursor and Replit, and at the short list of mistakes that keep turning up in them.

More about the author

Read next

All articles

Back up your Supabase database before your next change

Reeve keeps an encrypted copy on a schedule and restores it for you if something breaks.

See Supabase backups

Automated external check, not a full audit. Absence of findings is not a guarantee of safety.