Building SaaS Applications with open-source tools
This guide outlines the implementation of a multi-tenant subscription system using Stripe and Supabase. It focuses on establishing a secure link between Stripe billing states and database-level access control, ensuring that features are gated based on active subscription status and tenant isolation.
Define the Multi-tenant Database Schema
Create an 'organizations' table to act as the tenant root. Every user profile must link to an organization_id. Create a 'subscriptions' table that mirrors Stripe subscription data to allow for fast, local permission checks without API calls.
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
stripe_customer_id TEXT UNIQUE
);
CREATE TABLE subscriptions (
id TEXT PRIMARY KEY,
org_id UUID REFERENCES organizations(id),
status TEXT,
price_id TEXT,
cancel_at_period_end BOOLEAN
);
ALTER TABLE profiles ADD COLUMN org_id UUID REFERENCES organizations(id);⚠ Common Pitfalls
- •Failing to index org_id on data tables, leading to slow queries as the SaaS scales
- •Storing sensitive billing details locally instead of just status and IDs
Implement Webhook Verification
Create a secure endpoint to receive Stripe events. You must verify the signature using your Stripe Webhook Secret to prevent unauthorized actors from spoofing subscription success events.
import Stripe from 'stripe';
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get('stripe-signature')!;
const event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
if (event.type === 'customer.subscription.updated') {
const subscription = event.data.object as Stripe.Subscription;
await updateSubscriptionStatus(subscription);
}
return new Response(JSON.stringify({ received: true }), { status: 200 });
}⚠ Common Pitfalls
- •Using the raw request body instead of the unparsed string for signature verification
- •Not returning a 200 status code immediately, causing Stripe to retry and potentially double-process events
Enforce Multi-tenant Row-Level Security (RLS)
Configure Supabase RLS policies to ensure users can only access data belonging to their organization. This is the primary defense against cross-tenant data leaks.
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can only access their org's documents" ON documents
FOR ALL TO authenticated
USING (
org_id = (SELECT org_id FROM profiles WHERE id = auth.uid())
);⚠ Common Pitfalls
- •Forgetting to enable RLS on new tables created during feature development
- •Recursive policy definitions that cause performance bottlenecks
Sync Stripe Customer to Organization
When a user signs up, create a record in your 'organizations' table. When they first initiate a checkout, create a Stripe Customer and save that ID back to your database to link future billing events to the correct tenant.
const customer = await stripe.customers.create({
email: userEmail,
metadata: { orgId: organizationId }
});
await supabase
.from('organizations')
.update({ stripe_customer_id: customer.id })
.eq('id', organizationId);⚠ Common Pitfalls
- •Creating duplicate Stripe customers for the same organization because of race conditions during checkout initialization
Implement Subscription-Gated Middleware
Create a middleware layer or higher-order component that checks the 'subscriptions' table for an 'active' or 'trialing' status before allowing access to premium routes or API endpoints.
const { data: sub } = await supabase
.from('subscriptions')
.select('status')
.eq('org_id', userOrgId)
.single();
if (!sub || sub.status !== 'active') {
return NextResponse.redirect('/billing');
}⚠ Common Pitfalls
- •Only checking for 'active' status and forgetting to handle 'trialing' or 'past_due' (which might still allow grace period access)
- •Hardcoding Price IDs in the code instead of using environment variables
What you built
By following this sequence, you establish a hardened multi-tenant architecture where billing status is synchronized from Stripe and enforced at the database level via RLS. This setup minimizes the risk of data leaks and provides a scalable foundation for usage-based billing or tiered feature access.