Abbi Labs

Guides

Stripe webhook signature verification for AI-built apps (and the "No signatures found" error)

By Pritesh Ghodge, Senior Solutions Engineer (cybersecurity) · Last updated 12 November 2026

Scan your app free — check your live site for leaked Stripe keys and open Supabase tables.

Abbi Labs looks at your live site from the outside: secret keys in the JavaScript (including Stripe sk_live_ and rk_live_ keys), Supabase tables readable with your public key, exposed .env and .git files, source maps, and security headers. No sign-up for the quick check.

No sign-up. Read-only public-resource and protocol metadata checks. No model prompts or tool execution. Scan only sites you own or are allowed to test.

Run on every deploy or use from Cursor / Claude Code via MCP →

Weekly AI-app security checklist

One short email a week: a practical check for apps built with Supabase, Next.js and AI tools (RLS, leaked keys, headers and more). Unsubscribe anytime.

When you ask Lovable, Bolt, v0, Cursor or Replit Agent to "add Stripe payments", you usually get a Checkout button and a webhook route that upgrades the user when a payment event arrives. That webhook route is a public URL. If it doesn't verify that the event really came from Stripe, anyone can send it a fake "payment succeeded" and get your paid plan for free. And when verification is there but broken, people often delete it to make the No signatures found error go away. Here's how to check yours and fix it properly.

1. Find out if your webhook verifies anything

Open the webhook handler (often app/api/stripe/webhook/route.ts, server/routes/webhook.ts or a Supabase function like supabase/functions/stripe-webhook). You're looking for a call to stripe.webhooks.constructEvent (or constructEventAsync) that uses the Stripe-Signature header and a whsec_ signing secret, before any database write. If the code does JSON.parse(body) or await req.json() and goes straight to event.type, it trusts whatever it's sent.

A prompt you can paste into your AI builder:

Show me our Stripe webhook handler. Does it verify the Stripe-Signature
header with stripe.webhooks.constructEvent (or constructEventAsync) using
the raw request body and the endpoint signing secret from a server-only
env var, before it changes any data? If not, fix it, return 400 when
verification fails, and show me the diff before applying it.

2. Verify with the raw body

The signature is computed over the exact bytes Stripe sent. If your framework parses the JSON first and you re-stringify it, the bytes change and verification fails every time. That's the number one cause of the error.

Next.js App Router (v0, Cursor, Bolt with Next):

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 body, not req.json()
  const sig = req.headers.get("stripe-signature");
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig!, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }
  // only now look at event.type and update your database
  return new Response("ok");
}

Express (Replit, Cursor backends): mount the webhook route with express.raw({ type: "application/json" }) and register it before any global express.json(), then pass req.body (a Buffer) to constructEvent.

Supabase Edge Functions (Lovable, Bolt with Supabase): read await req.text() and use the async version, because Deno uses Web Crypto:

const cryptoProvider = Stripe.createSubtleCryptoProvider();
const event = await stripe.webhooks.constructEventAsync(
  body, sig, Deno.env.get("STRIPE_WEBHOOK_SECRET")!, undefined, cryptoProvider
);

Stripe doesn't send a Supabase JWT, so webhook functions are usually deployed with JWT verification off. That's fine, but it means the Stripe signature check is the only thing protecting that function. It has to be there.

3. Use the right signing secret

Each endpoint has its own whsec_ secret. The one printed by stripe listen on your laptop is different from the one on your Dashboard endpoint, and test mode and live mode endpoints differ too. When you go live, add the live endpoint's secret to your production environment. Keep it (and your sk_live_ or restricted key) in server-only env vars or Supabase secrets, never with a NEXT_PUBLIC_ or VITE_ prefix. More: NEXT_PUBLIC_ and VITE_ variables are shipped to every visitor.

4. Don't fulfil from the success page

If your app unlocks the plan when the user lands on /success?session_id=..., anyone can open that URL. Fulfil from the verified checkout.session.completed event (check payment_status is paid for delayed payment methods), or retrieve the session from Stripe on your server and check it there. Look up the user from data you stored when creating the session (for example client_reference_id or metadata), not from anything the browser sends afterwards.

5. Handle retries and duplicates

Stripe retries events that don't get a 2xx response, and the same event can arrive more than once. Store the event.id you've processed and skip repeats, so a retry doesn't add credits twice. Return 2xx quickly and do slow work after.

6. Test it like an attacker would (on your own app)

Send your own webhook URL a request with no signature:

curl -s -o /dev/null -w "%{http_code}\n" -X POST https://your-app.com/api/stripe/webhook \
  -H "Content-Type: application/json" \
  -d '{"type":"checkout.session.completed","data":{"object":{}}}'

You want 400 and no change in your database. A 200 means it accepted an unsigned event. Then use stripe trigger checkout.session.completed with stripe listen --forward-to to confirm real events still work. Only test apps you own.

What an outside scan adds, and what it can't do

An outside scan can't see your webhook code or safely fire fake events at it, so steps 1 to 6 are on you. What it can catch is the other common Stripe mistake in AI-built apps: a live secret or restricted key shipped in the frontend JavaScript. Abbi Labs' free quick check looks for sk_live_ and rk_live_ keys in the JS your site serves, along with Supabase tables readable with your public key, exposed .env/.git files and security headers. If a key ever shipped, roll it in the Stripe Dashboard. Removing it from code doesn't un-leak it.

Keep it that way

Abbi Labs scans your live app for leaked keys, Supabase tables readable with your public key, exposed .env/.git files and security headers. Monitor re-scans weekly and after deploys, and emails you only when something new appears.

Scan your app free

FAQ

What happens if my Stripe webhook doesn't verify the signature?

Your webhook URL is public. Without the signature check, anyone who finds it can POST a JSON body that looks like checkout.session.completed or invoice.paid, and your code will treat it as a real payment: upgrade an account, mark an order paid, or add credits. Verifying the Stripe-Signature header with your endpoint's signing secret (whsec_...) is what proves the event came from Stripe.

Why do I get "No signatures found matching the expected signature for payload"?

Almost always one of three things: the body was parsed as JSON before verification (the check needs the exact raw bytes Stripe sent), the signing secret is for a different endpoint or mode (the Stripe CLI, each Dashboard endpoint, and test vs live mode all have their own whsec_ secret), or a proxy or framework changed the body. Read the raw body, use the secret for that exact endpoint, and don't turn the check off to make the error go away.

Is the webhook signing secret the same as my Stripe secret key?

No. The secret key (sk_live_ or sk_test_, or a restricted rk_ key) calls the Stripe API. The signing secret (whsec_) only verifies incoming webhooks. Both belong on the server only, never in a NEXT_PUBLIC_ or VITE_ variable or anything the browser downloads.

Can I just unlock the plan on my success page instead?

Don't. The success_url is a normal page anyone can open, and the session_id in it can be copied. Fulfil from the verified webhook (or by retrieving the Checkout Session from Stripe on your server and checking its payment_status), never from the redirect alone.

Does Abbi Labs test my Stripe webhook?

No. An outside scan can't safely send fake events to your webhook or see your server code. The free quick check does look for Stripe live secret and restricted keys (sk_live_, rk_live_) in the JavaScript your site serves, plus Supabase tables readable with your public key, exposed .env/.git files and security headers. Use the steps in this guide for the webhook itself.

Related guides

Weekly AI-app security checklist

One short email a week: a practical check for apps built with Supabase, Next.js and AI tools (RLS, leaked keys, headers and more). Unsubscribe anytime.