DEV Community

Tillping for Tillping

Posted on Originally published at tillping.online

Stripe webhook to a Supabase Edge Function returns 401: how to fix it

This article was created with the help of AI. Each step was checked against the Supabase and Stripe docs listed at the end.

Short answer: Supabase checks every call to an Edge Function for a login token (a JWT). Stripe does not send one. So Supabase turns Stripe away with a 401 before your code runs. Turn that check off for the webhook function only, then check Stripe's signature in your code.

Signs you have this problem

  • In Stripe, the webhook's Event deliveries tab shows failed deliveries with status 401.
  • The reply body says something like Missing authorization header or Invalid JWT.
  • Your function logs show nothing, because your code never ran.

If the 401 body is a message you wrote, the 401 comes from your own code, not from Supabase. Look at your handler instead.

Step 1: Turn off the JWT check for this one function

Pick one way.

A. In supabase/config.toml (best, because it lives in your code):

[functions.stripe-webhook]
verify_jwt = false
Enter fullscreen mode Exit fullscreen mode

Then deploy again:

supabase functions deploy stripe-webhook
Enter fullscreen mode Exit fullscreen mode

B. With a flag when you deploy:

supabase functions deploy stripe-webhook --no-verify-jwt
Enter fullscreen mode Exit fullscreen mode

C. In the Supabase Dashboard: open the function and switch off JWT verification.

Keep config.toml and the Dashboard setting the same.

When you test on your laptop, use supabase functions serve --no-verify-jwt.

Important: with the check off, anyone who knows the address can call this function. The Stripe signature check in Step 2 is now your only lock. Do not skip it. Keep the JWT check on for all your other functions.

Step 2: Check the Stripe signature in your code

Three rules:

  1. Read the body as raw text with await req.text(). Do not call req.json() first, or the signature will never match.
  2. On Supabase (which runs Deno), use constructEventAsync with Stripe's crypto provider. The plain constructEvent does not work here.
  3. Use the signing secret (whsec_...) of this exact endpoint.
// supabase/functions/stripe-webhook/index.ts
import Stripe from 'npm:stripe@^22' // version taken from Supabase's own example

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')
  if (!signature) return new Response('Missing signature', { status: 400 })

  const body = await req.text() // raw text, not JSON

  let event: Stripe.Event
  try {
    event = await stripe.webhooks.constructEventAsync(
      body,
      signature,
      Deno.env.get('STRIPE_WEBHOOK_SIGNING_SECRET')!,
      undefined,
      cryptoProvider,
    )
  } catch (err) {
    console.error('Bad signature:', (err as Error).message)
    return new Response('Invalid signature', { status: 400 })
  }

  // Handle the events you need here, e.g. checkout.session.completed
  console.log(`Got ${event.type} (${event.id})`)

  return Response.json({ received: true })
})
Enter fullscreen mode Exit fullscreen mode

This matches Supabase's own example (examples/edge-functions/supabase/functions/stripe-webhooks/index.ts in the supabase/supabase repo). Their example wraps the handler in withSupabase({ auth: 'none' }) from npm:@supabase/server. Plain Deno.serve does the same job.

Save your keys as secrets, not in code:

supabase secrets set STRIPE_API_KEY=sk_live_... STRIPE_WEBHOOK_SIGNING_SECRET=whsec_...
Enter fullscreen mode Exit fullscreen mode

What often goes wrong next

You see What it means Fix
400 "No signatures found matching the expected signature" Wrong secret, or the body changed Use the whsec_ of this endpoint. Test and live have different secrets. The stripe listen secret is different from the Dashboard one.
500 "SubtleCryptoProvider cannot be used in a synchronous context" You used constructEvent Use constructEventAsync with Stripe.createSubtleCryptoProvider()
200 but the user is not upgraded The event arrived, but the database write failed or found no user Read your function logs for the database error. Check how you match the Stripe customer or session to a user in your app.

Test it

On your laptop:

supabase functions serve stripe-webhook --no-verify-jwt --env-file ./supabase/.env
stripe listen --forward-to http://localhost:54321/functions/v1/stripe-webhook
stripe trigger checkout.session.completed
Enter fullscreen mode Exit fullscreen mode

Put the whsec_ that stripe listen prints into your local .env file.

After you deploy, check that the JWT wall is gone. Send a POST with no signature to your own function:

curl -i -X POST https://YOUR-PROJECT-REF.supabase.co/functions/v1/stripe-webhook
Enter fullscreen mode Exit fullscreen mode
  • A 401 that mentions a JWT or a missing authorization header means Supabase is still blocking outside callers. Go back to Step 1.
  • A 400 Missing signature comes from your own code. That is good: Stripe can now reach it.

Then send a test event from the Stripe Dashboard and look at Event deliveries. You want a 2xx.

Sources


More webhook fix guides from Tillping: https://tillping.online/fix?ref=dev

Run a free check, or join the early-bird list. Only scan an address you own or are allowed to test. The free check sends safe test requests (a POST with no signature, a POST with a fake signature, and a GET), never real payment events. If your address redirects, it follows the redirect.

Top comments (0)