Auth and server code

Verify Stripe webhook signatures in a Supabase Edge Function

To verify a Stripe webhook in a Supabase Edge Function, read the request body as raw text, then pass it with the Stripe-Signature header and your webhook signing secret to stripe.webhooks.constructEventAsync() using Stripe’s Web Crypto provider; reject the request if that throws. Without this check, anyone who finds the URL can send a fake “payment succeeded” event.

TL;DR

  • Stripe signs every webhook. Unverified handlers let anyone fake payments (PL-021).
  • Read the body with req.text(). Parsing JSON first breaks the signature check.
  • In Deno, use constructEventAsync with Stripe.createSubtleCryptoProvider().
  • Set verify_jwt = false for this one function (Stripe has no Supabase token), and record event ids, because Stripe can deliver the same event more than once.

Why the signature matters

A webhook endpoint is a public URL that changes your database: “checkout completed, give this user Pro”. If the handler trusts the JSON it receives, anyone can send:

a forged event
curl -X POST https://<project>.supabase.co/functions/v1/stripe-webhook \
  -H "Content-Type: application/json" \
  -d '{"type":"checkout.session.completed","data":{"object":{"client_reference_id":"<any user id>"}}}'

Stripe prevents this by signing each delivery with a secret that only you and Stripe know (it starts whsec_). The signature covers the exact bytes of the body plus a timestamp, so a forged or replayed event fails verification.

Set up the secrets

  1. In the Stripe dashboard, add a webhook endpoint pointing at https://<project>.supabase.co/functions/v1/stripe-webhook and choose the events you handle (for example checkout.session.completed).
  2. Copy the endpoint’s signing secret.
  3. Store both keys as function secrets, never in code:
terminal
supabase secrets set STRIPE_SECRET_KEY=sk_live_... STRIPE_WEBHOOK_SECRET=whsec_...

Stripe can’t send a Supabase sign-in token, so turn off the platform JWT check for this function only. The signature is what proves the caller here.

supabase/config.toml
[functions.stripe-webhook]
verify_jwt = false

The function

supabase/functions/stripe-webhook/index.ts
import Stripe from 'npm:stripe@17';
import { createClient } from 'npm:@supabase/supabase-js@2';

const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!);
const cryptoProvider = Stripe.createSubtleCryptoProvider();
const admin = createClient(
  Deno.env.get('SUPABASE_URL')!,
  Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
);

Deno.serve(async (req) => {
  const signature = req.headers.get('Stripe-Signature');
  if (!signature) return new Response('Missing signature', { status: 400 });

  // 1. The raw body, exactly as Stripe sent it
  const body = await req.text();

  // 2. Verify. Throws if the signature, secret or timestamp don't match.
  let event: Stripe.Event;
  try {
    event = await stripe.webhooks.constructEventAsync(
      body,
      signature,
      Deno.env.get('STRIPE_WEBHOOK_SECRET')!,
      undefined,
      cryptoProvider,
    );
  } catch (_err) {
    return new Response('Invalid signature', { status: 400 });
  }

  // 3. Skip events already handled (Stripe may deliver more than once)
  const { error: seenErr } = await admin
    .from('stripe_events')
    .insert({ id: event.id, type: event.type });
  if (seenErr) {
    // 23505 = unique violation: we've processed this event before
    if (seenErr.code === '23505') return Response.json({ received: true });
    return new Response('Could not record event', { status: 500 });
  }

  // 4. Act on the verified event
  if (event.type === 'checkout.session.completed') {
    const session = event.data.object as Stripe.Checkout.Session;
    if (session.payment_status === 'paid' && session.client_reference_id) {
      await admin.from('profiles')
        .update({ plan: 'pro' })
        .eq('id', session.client_reference_id);
    }
  }

  return Response.json({ received: true });
});
supabase/migrations/…_stripe_events.sql
-- Event ids already processed. Only the service role writes here.
create table public.stripe_events (
  id text primary key,
  type text not null,
  received_at timestamptz not null default now()
);
alter table public.stripe_events enable row level security;
-- No policies: the API roles can't read or write it; the service role can.

Details that trip people up

  • Raw body. await req.json() and then JSON.stringify doesn’t reproduce the original bytes, so verification fails. Read req.text() once and parse only the verified event.
  • Async in Deno. Node examples use constructEvent. In an Edge Function use constructEventAsync with the Web Crypto provider, as above.
  • Which secret. The signing secret is per endpoint, and the Stripe CLI has its own for local testing. A “No signatures found matching the expected signature” error usually means the wrong whsec_ value.
  • Who the payment is for. Set client_reference_id (or metadata) when you create the Checkout Session on your server, from the signed-in user’s id. Don’t let the browser choose it.
  • Retries. Stripe retries deliveries that fail or time out, which is why the function records event ids. If the work after recording an event fails, delete that event’s row and return a 500, so Stripe’s retry can process it again.

Test it locally with the Stripe CLI

terminal
# 1. Serve functions locally (needs supabase start)
supabase functions serve --env-file supabase/functions/.env

# 2. Forward Stripe test events to it; note the whsec_ secret it prints
stripe listen --forward-to http://127.0.0.1:54321/functions/v1/stripe-webhook

# 3. Trigger a signed test event
stripe trigger checkout.session.completed

# 4. A forged, unsigned event must be refused with 400
curl -i -X POST http://127.0.0.1:54321/functions/v1/stripe-webhook \
  -H "Content-Type: application/json" \
  -d '{"type":"checkout.session.completed","data":{"object":{}}}'

Put the secret printed by stripe listen in supabase/functions/.env as STRIPE_WEBHOOK_SECRET for local runs, and keep that file out of git.

Other payment providers

Lemon Squeezy, Paddle and others also sign their webhooks, each with its own header and scheme. Use the provider’s documented verification, always over the raw body, and compare signatures in constant time. If a provider doesn’t sign, treat the webhook as a hint: fetch the payment from the provider’s API with your secret key and act on that.

Sources

The checks behind this guide, each with example code, a self-check and the fix.

Free check

Check your app for this, free.

Paste your public repo. PatchLabs rebuilds your database from its migrations, tests it as a visitor, a stranger and the owner, reads the code for keys and risky patterns, and hands you the fix.

Free for your first appNever touches your database

Written by Manraj Singh Dhillon. Last checked 10 Nov 2026. Code samples are synthetic and run against your own project; nothing here names a real app. Spotted a mistake? Tell us.