Blog

How to verify Stripe webhook signatures in Supabase Edge Functions and Next.js

An unverified Stripe webhook lets anyone fake a paid event. How to verify the signature in Supabase Edge Functions and Next.js.

Free check

Check the live app

Paste the public URL. The check reads the page a visitor already gets, then gives you a private report. It does not log in or change the app.

Free, one time, and limited to what a stranger can already see. No account required.

A Stripe webhook is an HTTPS POST Stripe sends when something happens in your account, like a payment. Stripe's webhook guide says the endpoint must be a public HTTPS URL, and your handler verifies each request with the JSON payload, the Stripe-Signature header, and the endpoint signing secret (it starts with whsec_). The path isn't a secret. Anyone who finds the URL can POST to it.

Skip the check and your handler trusts the JSON. A body with type set to payment_intent.succeeded (the event Stripe's CLI example triggers) runs your real-payment code: mark the order paid, grant the plan, unlock the file. The sender doesn't need your Stripe secret key or a successful charge. Just the URL.

Supabase function? Use the Edge Function steps. Next.js route.ts? Use the route-handler steps. Lovable, Bolt, Replit, v0, and Cursor projects can end up either way. Short guides: Lovable, Bolt, Replit, v0, and Cursor.

What you pass to the signature check

Stripe's page on webhook endpoint problems says verification takes three parameters:

  • The raw request body, the exact string Stripe sent.
  • The Stripe-Signature header.
  • This destination's endpoint secret. It starts with whsec_.

On Node.js, the library call for a snapshot event is constructEvent. On Supabase Edge Functions (they run on Deno), it's constructEventAsync. If it throws, return 400 and don't touch the order.

No signatures found matching the expected signature for payload

Stripe's documented error when verification rejects a delivery:

No signatures found matching the expected signature for payload

It means at least one of the three parameters is wrong. Stripe says the most common cause is the wrong endpoint secret. The value stripe listen prints isn't the secret on your dashboard endpoint, even though both start with whsec_. Local forwarding uses the CLI secret. The deployed URL uses the one you reveal on that endpoint.

The other cause is the body, which has to be the exact string Stripe sent. Parse it, reorder keys, strip whitespace, convert it to JSON, or change the encoding, and the check fails. A generated handler does this when it calls request.json() to read type, then passes JSON.stringify of that object to constructEvent. The event can be real and still fail.

The header should look like t=...,v1=.... If it doesn't, your code is reading Stripe-Signature wrong. An empty header is a failed check. Return 400. Don't fulfill the event just to clear the error.

Read the raw body, not parsed JSON

Stripe's webhook guide says any change to the raw body breaks verification. Read the body as text, verify, then use the event object the library returns. Return a 2xx before slow work so Stripe doesn't time out and retry. Stripe's example of work that should wait: marking an invoice paid in an accounting system.

Next.js route handlers

A route handler is a route.ts or route.js file under app. The route handler reference says POST receives a Web Request, and its webhook example reads the payload with request.text(). Another example on that page uses request.json(), which parses JSON. Verification needs request.text().

Next.js says you can only read a request body once. Keep that string, pass it to constructEvent, and don't call request.json() first.

App Router files don't use a bodyParser setting. That's a Pages Router thing. Stripe's endpoint page says a Pages Router handler must turn bodyParser off and read the raw buffer. Only do that if your webhook still lives in pages/api.

Call constructEvent with the raw string, the header, and the secret from a server environment variable. Return 400 on failure, 200 when you accept the event. Don't name the variable NEXT_PUBLIC_STRIPE_WEBHOOK_SECRET. Any NEXT_PUBLIC_ value goes to the browser. Which keys can be public is in API keys in the frontend. pk_ in the page is normal. whsec_ in the page is not.

Supabase Edge Functions

Function configuration says Edge Functions require a valid JWT by default. Stripe doesn't send one. Supabase's example sets verify_jwt = false for a function named stripe-webhook in supabase/config.toml, and the page warns that anyone can then invoke it. That lets the POST in. It doesn't prove Stripe sent it.

Securing Edge Functions says to use auth: 'none' for a signed webhook, then verify the provider's signature in the handler. With auth: 'none', authenticating the caller is the handler's job. Leave verify_jwt on for functions your app calls with a user session.

constructEvent() throws SubtleCryptoProvider cannot be used in a synchronous context on Deno, since Deno has no synchronous Node crypto. Create Stripe.createSubtleCryptoProvider() and call constructEventAsync instead.

Supabase's Stripe webhook example reads the signature header, reads the body with req.text(), and calls constructEventAsync with that body, the signature, Deno.env.get("STRIPE_WEBHOOK_SIGNING_SECRET"), and the crypto provider. Its comment says .text() is required because verification uses the raw body, not parsed JSON. Failure returns 400. The securing page calls it STRIPE_WEBHOOK_SECRET instead. Either name works if the value is this endpoint's whsec_ secret. Don't put it in a VITE_ variable. Vite copies those into the browser bundle.

For local serving, the configuration page shows --no-verify-jwt. You still need the signature check inside the function. Set verify_jwt = false on this function only.

Keep the signing secret on the server

whsec_ is how your server knows it's Stripe. If that string is in JavaScript the browser downloads, a visitor can build a Stripe-Signature header your check will accept. Read it with process.env in the route or Deno.env.get in the function. Don't paste it into source, return it, or log it anywhere a client can read. Already shipped it in a bundle? Remove it and roll the secret in the Stripe dashboard.

Keep .env, .env.local, and .env.production off the public site. A NAME=value file at /.env hands your secret list to anyone who opens that path. Store the signing secret in the host environment or the function secrets.

The same event arrives more than once

Stripe retries live deliveries for up to three days with exponential backoff, and sandbox deliveries three times over a few hours. A dashboard or CLI resend can also deliver an event you already handled. The webhook guide says to log the event ids you've processed and skip any that are already logged. Return a 2xx for the duplicate so Stripe stops retrying.

Delivery order isn't guaranteed either. Stripe's subscription example can produce customer.subscription.created, invoice.created, invoice.paid, and charge.created, and a later step can arrive first. Snapshot events record created in seconds, so two events can share a timestamp. Stripe says not to use created to decide order or to spot duplicates. Store event.id. When two different events refer to one object, the guide says you can also use the id in data.object with event.type.

Insert event.id before you grant access, with a unique constraint on the id. If the insert conflicts, return 200 and don't grant access again.

Test it with the Stripe CLI

Stripe's webhook guide says to test in a sandbox or with the CLI before you go live. Forward events to your local route:

stripe listen --forward-to localhost:4242/webhook

Swap in your handler's port and path. The command prints a signing secret. Put it in the local environment your handler reads. It won't match the dashboard secret.

Send a sample payment event:

stripe trigger payment_intent.succeeded

A 400 with No signatures found matching the expected signature for payload means the body was parsed before verification, the process is using the dashboard secret while the CLI is forwarding, or the header is missing or doesn't look like t=...,v1=.... A 200 means the signature matched. Confirm the event id was stored, trigger it again, and confirm access was granted only once.

stripe listen --forward-to runs in your terminal. Stripe's guide says the Workbench shell can't use --forward-to. Once you register the public URL, set that endpoint's own whsec_ secret on the host and send one test-mode payment. If your handler only passes under stripe listen, it's still on the CLI secret.

What a free Deploy Safely check can see

The free check loads your public page and the JavaScript it loads, inline scripts included, and looks for a fixed set of secret shapes. A Stripe webhook signing secret is whsec_ followed by 16 or more letters or digits. That match is a high finding titled "A Stripe webhook secret is in the public page." A secret or restricted key (sk_ or rk_, with live or test) is critical. A publishable key (pk_) is an info note, since that key belongs in the browser. The check doesn't save the full secret. It keeps a short hint of the shape and doesn't use the value.

The same pass requests /.env, /.env.local, /.env.production, /.git/config, /.git/HEAD, and /.npmrc. It reports an env file when that URL returns a non-HTML 200 with NAME=value lines, and it lists the names without saving the values. It notes missing browser safety headers when two or more of the watched headers are absent on the main response.

It does not detect an unverified webhook. It doesn't call your webhook URL, read server-only code, or look for constructEvent or constructEventAsync. A source map is a separate check. The scanner fetches a map only when a script already points at one, and reports it only when original source is included. Secret patterns aren't applied to the map file, so if that finding shows up, open the map yourself. More on the file checks in leaked .env, .git, and source maps.

So a clean report doesn't mean your handler checks the signature. Open the route or Edge Function and confirm the raw body, the header, and the endpoint secret. The checklist says to check it yourself for a whsec_ value in your live JavaScript, which is the part this scan does flag.

A prompt you can paste

Paste this into the builder, publish again, and run the check a second time. If a secret was already on the live site, rotate it in that vendor's dashboard. The prompt cannot do that part.

Make the Stripe webhook verify the signature before it grants access or marks an order paid.

1. Find the webhook file. In Next.js it is a route.ts POST handler. In Supabase it is an Edge Function. Read the raw body with request.text() or req.text() and pass that exact string to Stripe. Do not call request.json(), req.json(), or JSON.parse before verification, and do not JSON.stringify the body to rebuild it.
2. Read the Stripe-Signature header. On Node.js, call stripe.webhooks.constructEvent(rawBody, signature, secret). In a Supabase Edge Function, create Stripe.createSubtleCryptoProvider() and call stripe.webhooks.constructEventAsync with the raw body, the signature, the secret, and that provider. The synchronous constructEvent call throws on Deno.
3. Read the secret from the server environment. Supabase's example uses STRIPE_WEBHOOK_SIGNING_SECRET with Deno.env.get. A Next.js route should use process.env in the route file. Do not prefix the name with NEXT_PUBLIC_ or VITE_. Do not paste a whsec_ literal into source, and do not print the secret.
4. If the header is missing or verification throws, return 400 and do not fulfill. On success, insert event.id into a table with a unique constraint before you grant access. If that id already exists, return 200 and skip fulfillment.
5. On Supabase, set verify_jwt = false only for this function in supabase/config.toml, so Stripe's POST can arrive. Keep the Stripe signature check in the function. Leave JWT checks on for every other function.
6. Use the secret printed by stripe listen only while that command is forwarding to your machine. Use the dashboard endpoint secret for the deployed URL. They are different whsec_ values.

Do not treat a payment_intent.succeeded body as paid until signature verification has succeeded.

What the free check looks at

  • The public page and the JavaScript it loads, for common leaked secret keys: a Supabase service role key, OpenAI, Anthropic, Stripe secret keys, AWS, GitHub, Slack, private keys, and database URLs that include a password. A Supabase anon key is called out as expected in the browser.
  • A short list of public paths: /.env, /.env.local, /.env.production, /.git/config, /.git/HEAD, and /.npmrc.
  • A source map, only when a script on the page already points at one, and only when that map includes original source.
  • Supabase tables, only when the page already includes a supabase.co address and the public anon key. It asks for one row per table, then drops that row.
  • Firebase Realtime Database and Firestore, only when the public config is already in the page. It uses that public config and does not sign in.
  • A few browser safety headers on the main response. Missing headers are noted when two or more of the watched ones are absent. They are less urgent than a leaked key.

What stays outside it

  • It reads. It does not log in, submit forms, or change data.
  • It does not guess passwords, fuzz inputs, or hunt through hidden paths.
  • It does not use any secret it finds, including a service role key.
  • It does not open your builder's dashboard. A rule that lives only there can stay invisible.
  • A clear result describes this public look. It is not a certificate that the whole app is safe.

Free check

Run it on the published URL

Use the address a customer would open. The report stays private, and the shareable scorecard hides the details.

Free, one time, and limited to what a stranger can already see. No account required.

Security news for vibe coders. Two lines a day.

One story for Lovable, Bolt, Cursor and Supabase apps. Plus a fix when there is one.

From me, Allie. Weekdays. Unsubscribe anytime.

Keep going

All builder guides · All notes