Secure Stripe Checkout with Supabase Edge Functions

Separate authenticated Checkout creation from public signed webhooks, use trusted prices, deduplicate events, and grant access only after reconciliation.

A secure payment flow distinguishes three kinds of success: the browser reaches a success page, Stripe confirms a payment, and the internal system settles an order or grants an entitlement. Only the second, delivered through a verified event, may trigger the third. A success URL is user experience, not payment evidence. It can be refreshed or opened directly, and the customer can close it before payment completion. As of 2026-07-29, Stripe's Checkout Sessions documentation still describes server-side Session creation. Stripe's webhook documentation requires signature verification using the raw request payload, Stripe-Signature , and endpoint secret, and it calls out duplicate delivery. Supabase lists Stripe integrations and webhooks as Edge Function use cases. Edge Functions use JWT verification by default; Stripe does not send a user's Supabase JWT, so only the webhook endpoint should explicitly disable that platform gate and replace it with Stripe signature verification. Implementation steps Split the flow into two functions. create-checkout-session accepts an authenticated user request. stripe-webhook accepts a public request from Stripe. Do not make one endpoint support both identity models. Keep JWT verification for the creation function. Set verify_jwt = false for the webhook in config.toml , then make signature verification the first meaningful handler action. [functions.create-checkout-session] verify_jwt = true [functions.stripe-webhook] verify_jwt = false The client may submit an internal product_id or plan key. It must not supply a trusted amount, currency, Stripe Price ID, customer ID, or success URL. The function loads an active product from a server-owned database allowlist, obtains its Stripe Price ID, and checks whether the authenticated user may purchase it. Redirect origins also come from a server allowlist rather than an arbitrary caller origin. Create an internal pending order before calling Stripe and derive an idempotency key from that order. Put an unguessable order ID into the Session's client_reference_id or metadata so the webhook can resolve it. Keep the secret key in Supabase project secrets only. It must never use a VITE_ client prefix, return to the browser, or appear in logs. This Edge handler is conceptual. Pin and test the current Stripe and Supabase imports and API version from their official quickstarts when implementing it: Deno.serve(async (req) => { const user = await requireAuthenticatedUser(req) const { productId } = await req.json() const product = await loadActiveProduct(productId) if (!product) return Response.json({ error: "invalid_product" }, { status: 400 }) const order = await createPendingOrder(user.id, product) const session = await stripe.checkout.sessions.create({ mode: product.mode, line_items: [{ price: product.stripe_price_id, quantity: 1 }], client_reference_id: order.id, success_url: `${ALLOWED_APP_ORIGIN}/checkout/success?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${ALLOWED_APP_ORIGIN}/checkout/cancelled`, }, { idempotencyKey: `checkout:${order.id}` }) return Response.json({ url: session.url }) }) Return only the URL or identifier needed by the client, not the Stripe secret or a complete Session object. CORS headers use an exact origin allowlist. The OPTIONS response can be public, while the real POST still requires authentication. Validate JSON shape, lengths, product state, and rate limits before creating a Session. The webhook obtains the raw body with await req.text() and reads Stripe-Signature . Verify first; only then parse JSON or pass the bytes to the SDK's event constructor. Parsing and re-stringifying before verification can alter bytes and make a valid signature fail. A failed signature returns 400 and must never reach a service-role database write. After verification, insert the Stripe event ID inside a transaction or a constraint-protected RPC. A unique conflict means the event is a duplicate, so return 2xx without granting the entitlement again. Handle only an event-type allowlist. When Checkout completes, verify the state appropriate to the payment mode rather than trusting an arbitrary event name. Reconcile amount, currency, Stripe customer/reference, and internal expectation, then transition the order from pending to paid and grant access in one controlled operation. Keep the service-role key in webhook server context only because it can bypass RLS. Narrow the settlement RPC to explicit transitions that verify current order status, provider reference, and event uniqueness. Do not expose a generic privileged function that grants a course whenever the caller supplies a user ID. Failure and recovery If Session creation times out, do not immediately create a second Session with a new key. Retry against the same internal order and idempotency key, or inspect whether the order already has a provider Session. If the product or price changed, close the old pending order and create a new order with a new key while preserving their relationship for audit. A webhook 401 commonly indicates that the Supabase JWT gate is still enabled for that function. The fix is not to make every function public. Change only stripe-webhook and prove that signature verification runs before all business work. For a 400 signature mismatch, verify that the endpoint secret belongs to the current endpoint, the raw body remained untouched, and a Stripe CLI secret was not confused with a Dashboard endpoint secret. If the database is unavailable, return a non-2xx response so Stripe can retry according to its platform behavior, while keeping the handler idempotent. Do not acknowledge first and rely on an in-memory task. If an event record was committed but entitlement settlement failed, preserve a processing or failed state with an error class so a controlled worker can retry the same event. If an incorrect entitlement was granted, disable the affected product or checkout feature flag, retain event, order, and log evidence,