Supabase Row Level Security for Multi-Tenant SaaS: A Practical Guide
How to use Supabase RLS policies to build secure multi-tenant SaaS applications. Covers tenant isolation, organization-based access, role hierarchies, and performance optimization.
On this page
- Why RLS Is Non-Negotiable for SaaS
- How RLS Works in Supabase
- Enabling RLS
- Basic User Isolation
- Organization-Based Multi-Tenancy
- Schema Design
- Organization Access Policies
- Role-Based Access Within Organizations
- Performance Optimization
- Index the Right Columns
- Use Security Definer Functions
- Measure the Impact
- Common Patterns for SaaS
- Public + Private Data
- Subscription-Gated Features
- Admin Override
- Testing Your RLS Policies
- The Security Layering Approach
- Related Guides
- Get Started With Secure Defaults
Launch your SaaS in 30 minutes with production-ready auth, payments, monitoring, and deployment. $49 one-time.
Get Instant AccessWhy RLS Is Non-Negotiable for SaaS
Every multi-tenant SaaS has one critical requirement: users must never see each other's data. A single data leak can destroy trust permanently.
Traditional approaches put this burden on application code — every query includes a WHERE user_id = ? clause. But this is fragile. One missed filter, one new endpoint that forgets the check, and you have a data breach.
Supabase Row Level Security moves this guarantee to the database layer. Even if your application code has a bug, the database itself enforces tenant isolation. This guide assumes you've already wired up Supabase auth — if not, start with our Next.js + Supabase authentication guide.
How RLS Works in Supabase
When you enable RLS on a table, PostgreSQL denies all access by default. You then create policies that define who can do what.
Every policy has access to auth.uid() — the ID of the currently authenticated user, extracted from the JWT that Supabase passes with every request.
Enabling RLS
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
After this, no queries will return results until you create policies.
Basic User Isolation
The simplest pattern — users can only access their own rows:
-- Users can read their own projects
CREATE POLICY "Users read own projects"
ON projects FOR SELECT
USING (user_id = auth.uid());
-- Users can insert their own projects
CREATE POLICY "Users insert own projects"
ON projects FOR INSERT
WITH CHECK (user_id = auth.uid());
-- Users can update their own projects
CREATE POLICY "Users update own projects"
ON projects FOR UPDATE
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
-- Users can delete their own projects
CREATE POLICY "Users delete own projects"
ON projects FOR DELETE
USING (user_id = auth.uid());
The USING clause filters which rows are visible. The WITH CHECK clause validates new or modified rows.
Organization-Based Multi-Tenancy
Most B2B SaaS products need organization-level access — multiple users sharing the same data within a team.
Schema Design
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
organization_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, organization_id)
);
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
Organization Access Policies
-- 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;
-- Users can see organizations they belong to
CREATE POLICY "Members can view organization"
ON organizations FOR SELECT
USING (
id IN (
SELECT organization_id FROM memberships
WHERE user_id = auth.uid()
)
);
-- Users can see projects in their organizations
CREATE POLICY "Members can view projects"
ON projects FOR SELECT
USING (
organization_id IN (
SELECT organization_id FROM memberships
WHERE user_id = auth.uid()
)
);
Role-Based Access Within Organizations
Not everyone in an organization should have the same permissions:
-- Only admins and owners can create projects
CREATE POLICY "Admins can create projects"
ON projects FOR INSERT
WITH CHECK (
organization_id IN (
SELECT organization_id FROM memberships
WHERE user_id = auth.uid()
AND role IN ('owner', 'admin')
)
);
-- Only owners can delete projects
CREATE POLICY "Owners can delete projects"
ON projects FOR DELETE
USING (
organization_id IN (
SELECT organization_id FROM memberships
WHERE user_id = auth.uid()
AND role = 'owner'
)
);
Performance Optimization
RLS policies add a subquery to every database operation. On large tables, this can become a bottleneck.
Index the Right Columns
Every column referenced in an RLS policy needs an index:
-- Critical for organization-based policies
CREATE INDEX idx_memberships_user_id ON memberships(user_id);
CREATE INDEX idx_memberships_org_id ON memberships(organization_id);
CREATE INDEX idx_memberships_user_org ON memberships(user_id, organization_id);
CREATE INDEX idx_projects_org_id ON projects(organization_id);
Use Security Definer Functions
For complex policies, wrap the logic in a function. PostgreSQL can cache the function result within a transaction:
CREATE OR REPLACE FUNCTION get_user_org_ids()
RETURNS SETOF UUID
LANGUAGE sql
SECURITY DEFINER
STABLE
AS $$
SELECT organization_id FROM memberships
WHERE user_id = auth.uid();
$$;
-- Simpler, faster policy
CREATE POLICY "Members can view projects"
ON projects FOR SELECT
USING (organization_id IN (SELECT get_user_org_ids()));
The STABLE marker tells PostgreSQL the function returns the same result within a single transaction, enabling caching.
Measure the Impact
Use EXPLAIN ANALYZE to check policy overhead:
EXPLAIN ANALYZE SELECT * FROM projects;
Compare execution time with and without RLS enabled. On a properly indexed table with 100K rows, the overhead should be under 2ms.
RLS is one of dozens of things you'll wire up. SaaSInMinutes ships pre-configured RLS policies, indexes, and tested security defaults alongside auth, payments, and AWS deployment. $49 one-time. Get instant access →
Common Patterns for SaaS
Public + Private Data
Some data (like published blog posts) should be public while drafts are private:
CREATE POLICY "Anyone can read published posts"
ON posts FOR SELECT
USING (published = true);
CREATE POLICY "Authors can read own drafts"
ON posts FOR SELECT
USING (author_id = auth.uid() AND published = false);
Subscription-Gated Features
Combine RLS with subscription status for feature gating:
CREATE POLICY "Pro users can access analytics"
ON analytics FOR SELECT
USING (
organization_id IN (
SELECT organization_id FROM subscriptions
WHERE status = 'active'
AND plan IN ('pro', 'enterprise')
)
AND organization_id IN (SELECT get_user_org_ids())
);
Admin Override
Service role keys bypass RLS entirely, which is useful for admin dashboards and background jobs:
// Admin client bypasses RLS
import { createClient } from '@supabase/supabase-js';
const adminClient = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // Never expose to the browser
);
Testing Your RLS Policies
Never assume your policies work. Test them explicitly:
-- Impersonate a user and verify they see only their data
SET request.jwt.claims = '{"sub": "user-uuid-here"}';
SELECT * FROM projects; -- Should only return this user's projects
-- Try to access another user's data
SET request.jwt.claims = '{"sub": "different-user-uuid"}';
SELECT * FROM projects WHERE id = 'specific-project-id'; -- Should return empty
In your application tests, create two test users, create data for each, and verify that User A cannot see User B's data.
The Security Layering Approach
RLS is your strongest defense, but it's not the only one:
- RLS policies: Database-level enforcement (cannot be bypassed by application bugs)
- API validation: Input sanitization and business logic checks
- Middleware: Authentication verification on every request
- Frontend: UI-level access control (for UX, not security)
Each layer catches what the others might miss. RLS is the safety net that catches everything.
Related Guides
- Next.js + Supabase Authentication: The Complete Guide — the auth layer your RLS policies depend on
- LemonSqueezy Payment Integration for Next.js — wire subscription status into RLS for plan gating
- Launch a SaaS in 30 Minutes — see how RLS fits the full SaaS stack
- Deploy Next.js on AWS EC2 + S3 + CloudFront — production deployment with secure defaults
Get Started With Secure Defaults
Wiring RLS by hand on every new SaaS — user isolation, organization-based access, indexed policies, security-definer functions, the testing harness — takes most teams 8–15 hours to get right. And it's the kind of work where mistakes are expensive: a missed policy is a data breach.
SaaSInMinutes ships it done. For $49 one-time, you get RLS policies pre-configured for user isolation, proper indexes, tested policies, plus auth, payments, monitoring, and AWS deployment. You extend the policies for your specific multi-tenancy model rather than building from zero.
Security isn't a feature you add later. It's the foundation you build on.
Written by SaaSInMinutes