Stripe sends a webhook to your endpoint, and the delivery fails with one of these:
No signatures found matching the expected signature for payload
Webhook signature verification failed
SubtleCryptoProvider cannot be used in a synchronous context
Stripe signs the exact bytes it sends. If your code parses the JSON before verifying, or uses the wrong signing secret, the signature no longer matches. The fix: read the raw body as text, verify it with constructEventAsync and the endpoint's own whsec_ secret, and only then parse the event.
I see this one a lot in apps built with Lovable, Bolt and Base44 on Supabase, usually when payments move from the Stripe CLI to a real endpoint.
Why it happens
-
The body was parsed before verification. Calling
req.json()and re-stringifying changes whitespace and key order, so the signature no longer matches. -
The wrong signing secret. Each endpoint has its own
whsec_secret, test and live mode differ, and the Stripe CLI uses another one for local forwarding. -
Synchronous verification in Deno. Edge Functions run on Deno and need
constructEventAsyncwith the SubtleCrypto provider; the synchronous method throws. -
A proxy or platform rewrote the request. A layer in between dropped the
Stripe-Signatureheader or changed the body.
Find the cause
- In Stripe, open the endpoint under Webhooks and read the failed delivery's response body.
- Confirm which mode (test or live) the event came from, and which secret the function reads.
- Log whether the
Stripe-Signatureheader arrives at all.
Fix 1: verify the raw body in a Supabase Edge Function
Store the endpoint's signing secret as a function secret (for example STRIPE_WEBHOOK_SIGNING_SECRET). Read the body with req.text(), verify it with constructEventAsync and the SubtleCrypto provider, then handle the event.
import Stripe from 'npm:stripe@^22'
const stripe = new Stripe(Deno.env.get('STRIPE_API_KEY')!)
const cryptoProvider = Stripe.createSubtleCryptoProvider()
Deno.serve(async (req) => {
const signature = req.headers.get('Stripe-Signature')
const body = await req.text() // the exact bytes Stripe signed
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, and make it safe to receive twice
return Response.json({ received: true })
})
The important line is await req.text(). Stripe's docs are blunt about it: verification needs the raw request body, and any manipulation of that body makes it fail.
Deploy the function with JWT verification off. Stripe proves itself with the signature, not with a Supabase token. Supabase's own example deploys it with supabase functions deploy --no-verify-jwt stripe-webhooks.
Fix 2: match secrets to environments
- Use the live endpoint's secret in production and the test endpoint's secret in staging. Stripe generates a unique secret for each endpoint, and the test and live secrets are different.
- When forwarding with the Stripe CLI, use the secret that
stripe listenprints.
Check the fix worked
- Send a test event from the Stripe dashboard: the delivery shows 200.
- Change one character of the secret: the delivery fails with 400. That proves the check is real.
- Pay with a test card end to end and confirm the order or subscription updates in your database.
Stop it coming back
- Handle each event idempotently (store the event id), because Stripe retries.
- Reconcile with the Stripe API every day, so a missed webhook never leaves an order unpaid in your system.
Quick answers
Can I skip signature verification? No. Without it, anyone can post a fake "payment succeeded" event to your endpoint and unlock paid features.
Why did it work locally? The Stripe CLI uses its own signing secret. Production needs the secret of the endpoint registered in the dashboard.
Sources: Stripe: Check the webhook signatures and Supabase example: Stripe webhooks in an Edge Function.
I first published this fix on our site: Fix "No signatures found matching the expected signature" from Stripe. To check your live app for exposed keys, missing security headers and broken basics in about a minute, run the free Launch Check.
I wrote this with AI assistance and reviewed it myself.
Top comments (0)