TechLead
Lesson 15 of 22
5 min read
Supabase

Multi-Tenant Applications with Supabase

Build SaaS applications with Supabase using RLS-based multi-tenancy, team management, and role-based access

Multi-Tenancy with Supabase

Multi-tenancy is the cornerstone of SaaS applications — multiple organizations share the same database but can only see their own data. Supabase's Row Level Security makes this pattern elegant and secure, enforcing tenant isolation at the database level.

🚀 Architecture Overview

  • Shared database: All tenants use the same tables
  • RLS isolation: Policies ensure data separation
  • Role-based access: Owner, admin, member roles within teams
  • Team switching: Users can belong to multiple organizations

Database Schema

-- Organizations (tenants)
CREATE TABLE organizations (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name text NOT NULL,
  slug text UNIQUE NOT NULL,
  created_at timestamptz DEFAULT NOW()
);

-- Organization memberships
CREATE TABLE memberships (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE,
  org_id uuid REFERENCES organizations(id) ON DELETE CASCADE,
  role text NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'member')),
  created_at timestamptz DEFAULT NOW(),
  UNIQUE(user_id, org_id)
);

-- Tenant-scoped data
CREATE TABLE projects (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  org_id uuid REFERENCES organizations(id) ON DELETE CASCADE NOT NULL,
  name text NOT NULL,
  description text,
  created_by uuid REFERENCES auth.users(id),
  created_at timestamptz DEFAULT NOW()
);

-- Enable RLS on all tables
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
ALTER TABLE memberships ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

RLS Policies for Multi-Tenancy

-- Helper function: check if user is a member of an org
CREATE OR REPLACE FUNCTION is_org_member(org_id uuid)
RETURNS boolean
LANGUAGE sql
SECURITY DEFINER
SET search_path = ''
AS $$
  SELECT EXISTS (
    SELECT 1 FROM public.memberships
    WHERE memberships.user_id = auth.uid()
      AND memberships.org_id = is_org_member.org_id
  );
$$;

-- Helper: check role within an org
CREATE OR REPLACE FUNCTION get_org_role(org_id uuid)
RETURNS text
LANGUAGE sql
SECURITY DEFINER
SET search_path = ''
AS $$
  SELECT role FROM public.memberships
  WHERE memberships.user_id = auth.uid()
    AND memberships.org_id = get_org_role.org_id;
$$;

-- Organizations: members can view their orgs
CREATE POLICY "Members can view their organizations"
  ON organizations FOR SELECT
  USING (is_org_member(id));

-- Projects: members can view org projects
CREATE POLICY "Members can view org projects"
  ON projects FOR SELECT
  USING (is_org_member(org_id));

-- Projects: only admins/owners can create
CREATE POLICY "Admins can create projects"
  ON projects FOR INSERT
  WITH CHECK (
    get_org_role(org_id) IN ('owner', 'admin')
  );

-- Projects: only admins/owners can delete
CREATE POLICY "Admins can delete projects"
  ON projects FOR DELETE
  USING (
    get_org_role(org_id) IN ('owner', 'admin')
  );

Invitation Flow

// Create an invitation
async function inviteToOrg(orgId: string, email: string, role: string) {
  const { data, error } = await supabase
    .from('invitations')
    .insert({
      org_id: orgId,
      email,
      role,
      token: crypto.randomUUID(),
      expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
    })
    .select()
    .single()

  // Send invitation email via Edge Function or server action
  await sendInvitationEmail(email, data.token)
  return data
}

// Accept an invitation
async function acceptInvitation(token: string) {
  const { data: { user } } = await supabase.auth.getUser()

  const { data: invite } = await supabase
    .from('invitations')
    .select('*')
    .eq('token', token)
    .eq('email', user.email)
    .gt('expires_at', new Date().toISOString())
    .single()

  if (!invite) throw new Error('Invalid or expired invitation')

  // Create the membership
  await supabase.from('memberships').insert({
    user_id: user.id,
    org_id: invite.org_id,
    role: invite.role,
  })

  // Mark invitation as accepted
  await supabase.from('invitations')
    .update({ accepted_at: new Date().toISOString() })
    .eq('id', invite.id)
}

Team Switching in the Client

// Fetch all orgs the user belongs to
async function getUserOrgs() {
  const { data } = await supabase
    .from('memberships')
    .select('role, organizations(id, name, slug)')
    .eq('user_id', (await supabase.auth.getUser()).data.user?.id)

  return data
}

// All queries scoped to active org
async function getOrgProjects(orgId: string) {
  const { data } = await supabase
    .from('projects')
    .select('*')
    .eq('org_id', orgId)      // RLS enforces this is allowed
    .order('created_at', { ascending: false })

  return data
}

💡 Key Takeaways

  • • Use RLS to enforce tenant isolation at the database level
  • • Create helper functions like is_org_member() for reusable policy logic
  • • Support multiple roles (owner, admin, member) per organization
  • • Scope all data queries with org_id and let RLS verify access
  • • Implement invitations with expiring tokens for secure onboarding

📚 Learn More

Continue Learning