Multi-tenant Security Patterns
Implementing team and organisation-based data isolation with RLS
Last updated: 8/15/2025
Multi-tenancy means isolating data between different groups (teams, organisations, or workspaces). RLS makes this bulletproof at the database level.
The Multi-tenant Pattern
Instead of users owning data directly, data belongs to organisations and users belong to organisations through memberships.
Users (Memberships) (Organisations) (Data)
This pattern lets you:
- Share data within teams
- Control access through roles
- Scale from solo users to enterprise customers
Basic Schema Design
Core Tables
-- Organisations (teams, companies, workspaces)
CREATE TABLE organisations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Users can belong to multiple organisations
CREATE TABLE memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL, -- references auth.users
organisation_id UUID REFERENCES organisations(id) NOT NULL,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, organisation_id)
);
-- All business data includes organisation_id
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organisation_id UUID REFERENCES organisations(id) NOT NULL,
name TEXT NOT NULL,
description TEXT,
created_by UUID NOT NULL, -- references auth.users
created_at TIMESTAMPTZ DEFAULT NOW()
);
Multi-tenant RLS Policies
Organisation Member Access
Users can only see data from organisations they belong to:
-- Enable RLS on all business tables
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Members can read projects from their organisations
CREATE POLICY "members_read_org_projects" ON projects
FOR SELECT USING (
organisation_id IN (
SELECT organisation_id
FROM memberships
WHERE user_id = auth.uid()
)
);
Role-based Write Access
Different roles get different permissions:
-- Only admins and owners can create projects
CREATE POLICY "admins_create_projects" ON projects
FOR INSERT WITH CHECK (
organisation_id IN (
SELECT organisation_id
FROM memberships
WHERE user_id = auth.uid()
AND role IN ('owner', 'admin')
)
);
-- Members can update projects they created
CREATE POLICY "creators_update_projects" ON projects
FOR UPDATE USING (
created_by = auth.uid()
AND organisation_id IN (
SELECT organisation_id
FROM memberships
WHERE user_id = auth.uid()
)
);
-- Only owners can delete projects
CREATE POLICY "owners_delete_projects" ON projects
FOR DELETE USING (
organisation_id IN (
SELECT organisation_id
FROM memberships
WHERE user_id = auth.uid()
AND role = 'owner'
)
);
Helper Functions for Cleaner Policies
Create functions to simplify policy logic:
-- Check if user is member of organisation
CREATE OR REPLACE FUNCTION is_org_member(org_id UUID)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM memberships
WHERE user_id = auth.uid()
AND organisation_id = org_id
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Check if user has specific role in organisation
CREATE OR REPLACE FUNCTION has_org_role(org_id UUID, required_role TEXT)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM memberships
WHERE user_id = auth.uid()
AND organisation_id = org_id
AND role = required_role
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
Now policies become much cleaner:
-- Much simpler policy using helper function
CREATE POLICY "members_read_projects" ON projects
FOR SELECT USING (is_org_member(organisation_id));
CREATE POLICY "owners_delete_projects" ON projects
FOR DELETE USING (has_org_role(organisation_id, 'owner'));
Membership Management
Adding Users to Organisations
-- Function to invite user to organisation
CREATE OR REPLACE FUNCTION invite_user_to_org(
user_email TEXT,
org_id UUID,
user_role TEXT DEFAULT 'member'
)
RETURNS UUID AS $$
DECLARE
target_user_id UUID;
membership_id UUID;
BEGIN
-- Find user by email
SELECT id INTO target_user_id
FROM auth.users
WHERE email = user_email;
IF target_user_id IS NULL THEN
RAISE EXCEPTION 'User not found: %', user_email;
END IF;
-- Check if inviter is admin/owner
IF NOT has_org_role(org_id, 'admin') AND NOT has_org_role(org_id, 'owner') THEN
RAISE EXCEPTION 'Insufficient permissions';
END IF;
-- Create membership
INSERT INTO memberships (user_id, organisation_id, role)
VALUES (target_user_id, org_id, user_role)
ON CONFLICT (user_id, organisation_id)
DO UPDATE SET role = EXCLUDED.role
RETURNING id INTO membership_id;
RETURN membership_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
Using from Your App
// Invite user to organisation
const { data, error } = await supabase
.rpc('invite_user_to_org', {
user_email: 'newuser@example.com',
org_id: organisationId,
user_role: 'member'
});
// Get user's organisations
const { data: orgs } = await supabase
.from('memberships')
.select(`
role,
organisations (
id,
name
)
`)
.eq('user_id', userId);
Advanced Patterns
Hierarchical Permissions
Some roles inherit permissions from others:
-- Function to check if user has at least a certain role level
CREATE OR REPLACE FUNCTION has_min_org_role(org_id UUID, min_role TEXT)
RETURNS BOOLEAN AS $$
DECLARE
user_role TEXT;
role_hierarchy INTEGER;
BEGIN
-- Get user's role in this org
SELECT role INTO user_role
FROM memberships
WHERE user_id = auth.uid() AND organisation_id = org_id;
IF user_role IS NULL THEN
RETURN false;
END IF;
-- Convert roles to hierarchy levels
role_hierarchy := CASE user_role
WHEN 'owner' THEN 3
WHEN 'admin' THEN 2
WHEN 'member' THEN 1
ELSE 0
END;
RETURN role_hierarchy >= CASE min_role
WHEN 'owner' THEN 3
WHEN 'admin' THEN 2
WHEN 'member' THEN 1
ELSE 0
END;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
Cross-organisation Data Sharing
Sometimes you need to share specific data across organisations:
-- Shared resources table
CREATE TABLE shared_resources (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
resource_id UUID NOT NULL,
resource_type TEXT NOT NULL,
shared_with_org_id UUID REFERENCES organisations(id) NOT NULL,
shared_by_org_id UUID REFERENCES organisations(id) NOT NULL,
permissions TEXT[] DEFAULT ARRAY['read'],
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Policy for accessing shared projects
CREATE POLICY "shared_projects_access" ON projects
FOR SELECT USING (
-- Normal org access OR shared access
is_org_member(organisation_id)
OR EXISTS (
SELECT 1 FROM shared_resources sr
JOIN memberships m ON sr.shared_with_org_id = m.organisation_id
WHERE sr.resource_id = projects.id
AND sr.resource_type = 'project'
AND m.user_id = auth.uid()
)
);
Testing Multi-tenant Security
Verify Isolation
// Sign in as user from Org A
await supabase.auth.signInWithPassword({
email: 'user-org-a@example.com',
password: 'password'
});
// Should only see Org A's projects
const { data: projectsA } = await supabase
.from('projects')
.select('*');
// Sign in as user from Org B
await supabase.auth.signInWithPassword({
email: 'user-org-b@example.com',
password: 'password'
});
// Should only see Org B's projects (different set)
const { data: projectsB } = await supabase
.from('projects')
.select('*');
// Verify no overlap in accessible data
console.assert(
!projectsA.some(a => projectsB.some(b => b.id === a.id)),
'Data isolation failed!'
);
Performance Optimisation
Index Membership Lookups
-- Speed up membership queries
CREATE INDEX idx_memberships_user_org ON memberships(user_id, organisation_id);
CREATE INDEX idx_memberships_org_role ON memberships(organisation_id, role);
-- Speed up organisation-scoped data queries
CREATE INDEX idx_projects_org_id ON projects(organisation_id);
CREATE INDEX idx_tasks_org_id ON tasks(organisation_id);
Cache User Memberships
For frequently accessed membership data, consider caching:
// Cache user's organisations in JWT custom claims
const getUserOrganisations = async (userId) => {
const { data } = await supabase
.from('memberships')
.select('organisation_id, role')
.eq('user_id', userId);
return data;
};
// Use in custom JWT claims for faster policy evaluation
Common Pitfalls
Forgetting Organisation Context
// Wrong: Creates project without organisation context
const { data } = await supabase
.from('projects')
.insert({ name: 'My Project' });
// Right: Always include organisation_id
const { data } = await supabase
.from('projects')
.insert({
name: 'My Project',
organisation_id: currentOrgId
});
Not Handling Multiple Memberships
Users might belong to multiple organisations. Your UI should let them switch context:
// Get user's organisations for org switcher
const { data: userOrgs } = await supabase
.from('memberships')
.select(`
role,
organisations (id, name)
`)
.eq('user_id', user.id);
// Filter all queries by current organisation
const { data: projects } = await supabase
.from('projects')
.select('*')
.eq('organisation_id', currentOrgId);
Multi-tenant RLS ensures data stays isolated between organisations while allowing flexible sharing and role-based access within teams. Start with simple membership patterns and evolve toward more sophisticated permission systems as your application grows.
Related Topics
Continue building your security knowledge:
- API Keys and Environment Setup - Safe handling of credentials
- Supabase Client Patterns - Common usage patterns
- Security Leak Prevention - Preventing API key leaks
- Security Incident Response - Emergency procedures for leaks