Guides

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.

4-6 hours5 steps
1

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.

schema.sql
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
2

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.

api/webhooks/stripe.ts
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
3

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.

rls_policies.sql
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
4

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.

lib/billing.ts
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
5

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.

middleware.ts
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.