To secure Supabase + Stripe integration, you need to: (1) handle all Stripe webhooks in Supabase Edge Functions with signature verification, (2) use the service role key only in Edge Functions for subscription updates, (3) link checkout sessions to Supabase user IDs via client_reference_id, (4) enforce RLS on subscription tables, and (5) never trust client-side payment confirmations. This blueprint ensures payment state stays server-authoritative.
TL;DR
Stripe webhooks are your source of truth for payment state-never trust client-side payment confirmations. Use Supabase Edge Functions for webhook handling, verify signatures on every request, and use the service key only in Edge Functions to update subscription status.
Webhook Handler (Edge Function) Supabase Stripe
import Stripe from 'stripe'
import { createClient } from '@supabase/supabase-js'
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!)
const webhookSecret = Deno.env.get('STRIPE_WEBHOOK_SECRET')!
Deno.serve(async (req) => {
const signature = req.headers.get('stripe-signature')!
const body = await req.text()
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
} catch (err) {
console.error('Webhook signature verification failed')
return new Response('Invalid signature', { status: 400 })
}
// Use service key for admin operations
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session
await supabase.from('subscriptions').upsert({
user_id: session.client_reference_id,
stripe_customer_id: session.customer,
status: 'active',
})
break
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription
await supabase.from('subscriptions').update({
status: 'canceled',
}).eq('stripe_customer_id', subscription.customer)
break
}
}
return new Response(JSON.stringify({ received: true }))
})
Creating Checkout Sessions
import Stripe from 'stripe'
import { createClient } from '@supabase/supabase-js'
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!)
Deno.serve(async (req) => {
// Verify user auth
const authHeader = req.headers.get('Authorization')!
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_ANON_KEY')!,
{ global: { headers: { Authorization: authHeader } } }
)
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return new Response('Unauthorized', { status: 401 })
}
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: 'price_xxx', quantity: 1 }],
success_url: `${req.headers.get('origin')}/success`,
cancel_url: `${req.headers.get('origin')}/cancel`,
client_reference_id: user.id, // Link to Supabase user
})
return new Response(JSON.stringify({ url: session.url }))
})
Never trust client payment confirmations. Users can manipulate client-side code. Only update subscription status from verified webhook events.
Security Checklist
Pre-Launch Checklist
Webhook signatures verified
Service key only in Edge Functions
Subscription status from webhooks only
RLS on subscriptions table
Checkout session linked to user
Related Integration Stacks
Stripe Webhooks Deep Dive Firebase + Stripe Alternative OAuth Security Patterns