Row-level security (RLS) is the difference between "our multi-tenant app probably doesn't leak data" and "it provably cannot leak data." Here's how to implement it with Supabase and why you should never ship a SaaS without it.
The problem
Every multi-tenant SaaS has the same vulnerability:
-- Without RLS, any authenticated user can do this:
SELECT * FROM invoices;
-- Returns ALL invoices for ALL tenants
Most developers "solve" this in application code:
// Application-layer filtering
const invoices = await db.invoices.findMany({
where: { organizationId: currentUser.orgId }
});
This works until someone forgets the where clause. One missed filter in one endpoint and you have a data breach. RLS makes it impossible at the database level.
Enable RLS on every table
-- Step 1: Enable RLS
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
-- Step 2: Define policies
CREATE POLICY "Users can only see their organization's invoices"
ON invoices
FOR SELECT
USING (organization_id = auth.jwt() ->> 'org_id');
CREATE POLICY "Users can only insert into their organization"
ON invoices
FOR INSERT
WITH CHECK (organization_id = auth.jwt() ->> 'org_id');
CREATE POLICY "Users can only update their organization's invoices"
ON invoices
FOR UPDATE
USING (organization_id = auth.jwt() ->> 'org_id');
CREATE POLICY "Users can only delete their organization's invoices"
ON invoices
FOR DELETE
USING (organization_id = auth.jwt() ->> 'org_id');
Now every query is automatically filtered. You can't accidentally return another tenant's data because the database won't let you.
Supabase makes this practical
Supabase provides auth.jwt() — a function that extracts claims from the authenticated user's JWT. This is the bridge between your auth system and your RLS policies.
-- auth.uid() returns the authenticated user's ID
-- auth.jwt() returns the full JWT payload
-- Policy: users see only their own data
CREATE POLICY "own_data" ON profiles
FOR ALL
USING (id = auth.uid());
-- Policy: users see their team's data
CREATE POLICY "team_data" ON projects
FOR SELECT
USING (
team_id IN (
SELECT team_id FROM team_members WHERE user_id = auth.uid()
)
);
Role-based access within a tenant
-- Custom JWT claim: role
CREATE POLICY "admins_can_delete" ON invoices
FOR DELETE
USING (
organization_id = auth.jwt() ->> 'org_id'
AND auth.jwt() ->> 'role' = 'admin'
);
CREATE POLICY "members_read_only" ON invoices
FOR SELECT
USING (organization_id = auth.jwt() ->> 'org_id');
Regular members can read. Only admins can delete. The database enforces it — not your application code.
Performance: it's fast
The number one concern with RLS is performance. In practice:
-- This policy uses an index scan, not a full table scan
CREATE POLICY "org_isolation" ON invoices
FOR ALL
USING (organization_id = auth.jwt() ->> 'org_id');
-- Make sure you have the index
CREATE INDEX idx_invoices_org ON invoices(organization_id);
With proper indexes, RLS adds <1ms overhead per query. PostgreSQL's query planner integrates RLS predicates directly into the query plan — they're not applied as a post-filter.
The pattern for every SaaS table
-- Template: apply to every tenant-scoped table
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id),
title TEXT NOT NULL,
content TEXT,
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ DEFAULT now()
);
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
-- Read: org members can see
CREATE POLICY "documents_select" ON documents
FOR SELECT USING (organization_id = auth.jwt() ->> 'org_id');
-- Create: org members can create (enforced org_id)
CREATE POLICY "documents_insert" ON documents
FOR INSERT WITH CHECK (organization_id = auth.jwt() ->> 'org_id');
-- Update: only creator or admin
CREATE POLICY "documents_update" ON documents
FOR UPDATE USING (
organization_id = auth.jwt() ->> 'org_id'
AND (created_by = auth.uid() OR auth.jwt() ->> 'role' = 'admin')
);
-- Delete: admin only
CREATE POLICY "documents_delete" ON documents
FOR DELETE USING (
organization_id = auth.jwt() ->> 'org_id'
AND auth.jwt() ->> 'role' = 'admin'
);
Testing RLS policies
-- Supabase lets you test policies by setting the JWT
SET request.jwt.claims = '{"sub": "user-123", "org_id": "org-456", "role": "member"}';
-- This should return only org-456's invoices
SELECT * FROM invoices;
-- This should fail (member, not admin)
DELETE FROM invoices WHERE id = 'some-id';
Common mistakes
1. Forgetting to enable RLS on new tables
Every migration that creates a table should include ALTER TABLE ... ENABLE ROW LEVEL SECURITY. Add this to your migration template.
2. Using application-level IDs instead of JWT claims
-- BAD: trusts the application to send the right org_id
USING (organization_id = current_setting('app.org_id'));
-- GOOD: uses the cryptographically signed JWT
USING (organization_id = auth.jwt() ->> 'org_id');
3. Not handling the service role
Supabase has a service_role key that bypasses RLS. Use it only in server-side code, never expose it to the client.
// Client-side: uses anon key (RLS enforced)
const supabase = createClient(URL, ANON_KEY);
// Server-side only: bypasses RLS for admin operations
const supabaseAdmin = createClient(URL, SERVICE_ROLE_KEY);
The bottom line
RLS is not optional for multi-tenant SaaS. It's the only reliable way to prevent data leakage. Application-level filtering is a "hope nothing goes wrong" strategy. RLS is a "provably cannot go wrong" strategy.
The setup is 10 minutes per table. The alternative is explaining to your customers why their data was visible to other tenants.
Multi-tenant data isolation is built into the AI SaaS Starter Kit — RLS policies, organization scoping, and role-based access are configured from day one.
Top comments (0)