Introduction
Row Level Security (RLS) is Postgres’s killer feature for building secure multi-tenant applications. Instead of implementing authorization logic in application code—scattered across dozens of API endpoints, prone to bugs and bypasses—RLS enforces security at the database level where it belongs.
The promise is compelling: write authorization rules once, apply them automatically to every query, and make SQL injection and authorization bypasses nearly impossible. But RLS is also where many developers stumble. Common mistakes include:
- Performance nightmares: Poorly written policies causing 10x query slowdowns
- Security gaps: Missing policies allowing unauthorized access
- Complexity explosions: Deeply nested JOINs making policies unmaintainable
- Testing blindspots: Policies that pass unit tests but fail in production
This guide provides a complete production playbook for RLS, from fundamentals to advanced patterns used by companies managing billions of rows across thousands of tenants.
Key Concepts
What is Row Level Security?
RLS allows you to define policies that filter rows returned by SELECT queries and restrict rows affected by INSERT, UPDATE, and DELETE operations—all transparently at the database level.
Without RLS (vulnerable to bugs):
// ❌ Authorization in application code
const { data } = await supabase
.from('documents')
.select('*')
.eq('workspace_id', currentWorkspace.id); // Easy to forget!
With RLS (enforced by database):
// ✅ Authorization automatic via RLS
const { data } = await supabase
.from('documents')
.select('*'); // workspace_id filter applied automatically
The RLS security model:
- Enable RLS on a table:
ALTER TABLE documents ENABLE ROW LEVEL SECURITY; - By default, NO ONE can access the table (locked down)
- Create policies to grant specific access
- Policies apply to ALL queries, no exceptions
RLS Policy Anatomy
CREATE POLICY "policy_name"
ON table_name
FOR operation -- SELECT | INSERT | UPDATE | DELETE | ALL
TO role -- authenticated | anon | public | service_role
USING (condition) -- Filter which rows are visible
WITH CHECK (condition); -- Filter which rows can be modified
Key distinctions:
| Clause | Purpose | Used For |
|---|---|---|
USING | Which rows you can SEE | SELECT, UPDATE, DELETE |
WITH CHECK | Which rows you can CREATE/MODIFY | INSERT, UPDATE |
FOR SELECT | Read operations only | Always uses USING |
FOR INSERT | Create operations only | Always uses WITH CHECK |
FOR UPDATE | Modify operations only | Uses both USING and WITH CHECK |
FOR DELETE | Delete operations only | Always uses USING |
Common mistake: Using FOR ALL instead of separate policies:
-- ❌ Confusing: Single policy for all operations
CREATE POLICY "workspace_access"
ON documents FOR ALL
TO authenticated
USING (workspace_id = auth.uid())
WITH CHECK (workspace_id = auth.uid());
-- ✅ Clear: Separate policies per operation
CREATE POLICY "workspace_select" ON documents
FOR SELECT TO authenticated
USING (workspace_id = auth.uid());
CREATE POLICY "workspace_insert" ON documents
FOR INSERT TO authenticated
WITH CHECK (workspace_id = auth.uid());
CREATE POLICY "workspace_update" ON documents
FOR UPDATE TO authenticated
USING (workspace_id = auth.uid())
WITH CHECK (workspace_id = auth.uid());
CREATE POLICY "workspace_delete" ON documents
FOR DELETE TO authenticated
USING (workspace_id = auth.uid());
The RLS Execution Model
When you execute a query with RLS enabled:
SELECT * FROM documents WHERE title = 'Proposal';
Postgres internally rewrites it to:
SELECT * FROM documents
WHERE title = 'Proposal'
AND (workspace_id = auth.uid()); -- RLS policy automatically applied
Critical insight: RLS policies are ANDed with your query. All policies for the operation must pass.
Technical Deep Dive
Pattern 1: Single-Tenant Access (User Owns Resources)
Scenario: Users can only access their own data.
-- Enable RLS
ALTER TABLE user_profiles ENABLE ROW LEVEL SECURITY;
-- Policy: Users see their own profile
CREATE POLICY "Users see own profile"
ON user_profiles FOR SELECT
TO authenticated
USING (user_id = auth.uid());
-- Policy: Users update own profile
CREATE POLICY "Users update own profile"
ON user_profiles FOR UPDATE
TO authenticated
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
-- Policy: Users cannot change their user_id
CREATE POLICY "User ID immutable"
ON user_profiles FOR UPDATE
TO authenticated
WITH CHECK (user_id = auth.uid() AND user_id = OLD.user_id);
Testing:
-- Set session to user 'abc-123'
SELECT auth.uid(); -- Returns 'abc-123'
-- This works (user's own data)
SELECT * FROM user_profiles WHERE user_id = 'abc-123';
-- This returns 0 rows (different user's data, blocked by RLS)
SELECT * FROM user_profiles WHERE user_id = 'xyz-789';
Pattern 2: Multi-Tenant with Workspace Membership
Scenario: Users belong to workspaces, can access workspace data.
-- Workspaces table
CREATE TABLE workspaces (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
created_at timestamptz DEFAULT now()
);
-- Workspace members (junction table)
CREATE TABLE workspace_members (
workspace_id uuid REFERENCES workspaces(id) ON DELETE CASCADE,
user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE,
role text NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
PRIMARY KEY (workspace_id, user_id)
);
-- Documents table (tenant-scoped data)
CREATE TABLE documents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid REFERENCES workspaces(id) ON DELETE CASCADE,
title text NOT NULL,
content text,
created_by uuid REFERENCES auth.users(id),
created_at timestamptz DEFAULT now()
);
-- Enable RLS on all tables
ALTER TABLE workspaces ENABLE ROW LEVEL SECURITY;
ALTER TABLE workspace_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
-- Policies: workspace_members
CREATE POLICY "Users see own memberships"
ON workspace_members FOR SELECT
TO authenticated
USING (user_id = auth.uid());
-- Policies: documents
CREATE POLICY "Workspace members see documents"
ON documents FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1 FROM workspace_members
WHERE workspace_members.workspace_id = documents.workspace_id
AND workspace_members.user_id = auth.uid()
)
);
CREATE POLICY "Workspace members create documents"
ON documents FOR INSERT
TO authenticated
WITH CHECK (
EXISTS (
SELECT 1 FROM workspace_members
WHERE workspace_members.workspace_id = documents.workspace_id
AND workspace_members.user_id = auth.uid()
)
);
CREATE POLICY "Document creators update own documents"
ON documents FOR UPDATE
TO authenticated
USING (created_by = auth.uid())
WITH CHECK (created_by = auth.uid());
CREATE POLICY "Workspace owners delete any document"
ON documents FOR DELETE
TO authenticated
USING (
EXISTS (
SELECT 1 FROM workspace_members
WHERE workspace_members.workspace_id = documents.workspace_id
AND workspace_members.user_id = auth.uid()
AND workspace_members.role = 'owner'
)
);
Key optimization: Index workspace_members for fast lookups:
CREATE INDEX idx_workspace_members_user_workspace
ON workspace_members(user_id, workspace_id);
CREATE INDEX idx_workspace_members_workspace_user
ON workspace_members(workspace_id, user_id);
Pattern 3: Role-Based Access Control (RBAC)
Scenario: Different permissions based on user role.
-- Add role column to workspace_members (already shown above)
-- Roles: 'owner', 'admin', 'member', 'viewer'
-- Policies with role checks
CREATE POLICY "Admins can update workspace settings"
ON workspaces FOR UPDATE
TO authenticated
USING (
EXISTS (
SELECT 1 FROM workspace_members
WHERE workspace_members.workspace_id = workspaces.id
AND workspace_members.user_id = auth.uid()
AND workspace_members.role IN ('owner', 'admin')
)
);
CREATE POLICY "Viewers can only read documents"
ON documents FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1 FROM workspace_members
WHERE workspace_members.workspace_id = documents.workspace_id
AND workspace_members.user_id = auth.uid()
AND workspace_members.role IN ('owner', 'admin', 'member', 'viewer')
)
);
CREATE POLICY "Members and above can create documents"
ON documents FOR INSERT
TO authenticated
WITH CHECK (
EXISTS (
SELECT 1 FROM workspace_members
WHERE workspace_members.workspace_id = documents.workspace_id
AND workspace_members.user_id = auth.uid()
AND workspace_members.role IN ('owner', 'admin', 'member')
)
);
Pattern 4: Performance-Optimized Policies
Problem: RLS policies with subqueries can be slow.
Slow policy (sequential scan on workspace_members):
CREATE POLICY "Documents policy (slow)"
ON documents FOR SELECT
TO authenticated
USING (
workspace_id IN (
SELECT workspace_id FROM workspace_members
WHERE user_id = auth.uid()
)
);
Fast policy (uses EXISTS with index):
CREATE POLICY "Documents policy (fast)"
ON documents FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1 FROM workspace_members
WHERE workspace_members.workspace_id = documents.workspace_id
AND workspace_members.user_id = auth.uid()
)
);
Performance comparison:
-- Test query performance
EXPLAIN ANALYZE
SELECT * FROM documents
WHERE title ILIKE '%proposal%';
-- Slow policy: Seq Scan on workspace_members (cost=0..1000)
-- Fast policy: Index Scan using idx_workspace_members_workspace_user (cost=0..8)
Key principle: Always use EXISTS instead of IN for membership checks.
Pattern 5: Bypass RLS for Service Role
Scenario: Admin operations need to bypass RLS.
// Service role client (bypasses RLS)
const supabaseAdmin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // ⚠️ Never expose to client
);
// No RLS policies applied (full access)
const { data: allDocuments } = await supabaseAdmin
.from('documents')
.select('*');
// Regular client (RLS enforced)
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_ANON_KEY!
);
// RLS policies automatically filter results
const { data: userDocuments } = await supabase
.from('documents')
.select('*');
Use cases for service role:
- Admin dashboards
- Bulk data exports
- Background jobs (cleanup, aggregation)
- System-level operations
Security warning: NEVER expose service role key to clients. Only use in server-side edge functions.
Pattern 6: Hybrid Authorization (RLS + Application Logic)
Scenario: Complex authorization requires application logic.
-- RLS handles tenant isolation
CREATE POLICY "Workspace members see documents"
ON documents FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1 FROM workspace_members
WHERE workspace_members.workspace_id = documents.workspace_id
AND workspace_members.user_id = auth.uid()
)
);
// Application handles fine-grained permissions
async function canEditDocument(userId: string, documentId: string) {
// RLS ensures user is workspace member
const { data: document } = await supabase
.from('documents')
.select('*, workspace_members!inner(role)')
.eq('id', documentId)
.single();
if (!document) return false; // Not a workspace member
// Application logic: Only admins can edit shared documents
if (document.is_shared && document.workspace_members[0].role !== 'admin') {
return false;
}
return true;
}
Best practice: RLS enforces tenant isolation, application enforces business rules.
Best Practices
1. Always Enable RLS on New Tables
-- Template for new tables
CREATE TABLE new_table (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid REFERENCES workspaces(id),
-- ... other columns
created_at timestamptz DEFAULT now()
);
-- Immediately enable RLS
ALTER TABLE new_table ENABLE ROW LEVEL SECURITY;
-- Add default deny policy (explicit lockdown)
-- Now add specific policies to grant access
2. Test Policies with Different Roles
-- Test as authenticated user
SET LOCAL ROLE authenticated;
SET LOCAL "request.jwt.claims" TO '{"sub": "user-123"}';
SELECT * FROM documents; -- Should see only workspace documents
RESET ROLE;
-- Test as anon (unauthenticated)
SET LOCAL ROLE anon;
SELECT * FROM documents; -- Should see nothing (no public data)
RESET ROLE;
3. Use Helper Functions for Complex Logic
-- Helper function: Check if user is workspace admin
CREATE OR REPLACE FUNCTION is_workspace_admin(workspace_id uuid)
RETURNS boolean AS $$
SELECT EXISTS (
SELECT 1 FROM workspace_members
WHERE workspace_members.workspace_id = $1
AND workspace_members.user_id = auth.uid()
AND workspace_members.role IN ('owner', 'admin')
);
$$ LANGUAGE sql STABLE SECURITY DEFINER;
-- Use in policies
CREATE POLICY "Admins manage workspace"
ON workspaces FOR UPDATE
TO authenticated
USING (is_workspace_admin(id));
4. Add Indexes to Support Policies
-- Analyze query plans
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM documents WHERE workspace_id = 'xxx';
-- Add indexes based on policy conditions
CREATE INDEX idx_documents_workspace ON documents(workspace_id);
CREATE INDEX idx_documents_created_by ON documents(created_by);
CREATE INDEX idx_workspace_members_lookup ON workspace_members(workspace_id, user_id);
5. Monitor RLS Performance
-- Find slow queries with RLS overhead
SELECT
query,
calls,
mean_exec_time,
max_exec_time
FROM pg_stat_statements
WHERE query LIKE '%documents%'
ORDER BY mean_exec_time DESC
LIMIT 10;
6. Document Policies
COMMENT ON POLICY "Workspace members see documents" ON documents IS
'Allows users to view documents in workspaces they are members of.
Checks workspace_members table for membership.
Requires index: idx_workspace_members_lookup';
Common Pitfalls
Pitfall 1: Forgetting to Enable RLS
-- ❌ Table without RLS = no security!
CREATE TABLE documents (...);
-- Forgot: ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
-- Anyone can access all data
SELECT * FROM documents; -- Returns everything!
Pitfall 2: Using IN Instead of EXISTS
-- ❌ Slow: Builds full list, then checks membership
USING (workspace_id IN (SELECT workspace_id FROM workspace_members WHERE user_id = auth.uid()))
-- ✅ Fast: Short-circuits on first match
USING (EXISTS (SELECT 1 FROM workspace_members WHERE workspace_id = documents.workspace_id AND user_id = auth.uid()))
Pitfall 3: Missing Indexes
-- ❌ Policy without index = sequential scan
CREATE POLICY "..." USING (
EXISTS (SELECT 1 FROM workspace_members WHERE user_id = auth.uid())
);
-- ✅ Add index to support lookup
CREATE INDEX idx_workspace_members_user ON workspace_members(user_id);
Pitfall 4: Overly Permissive WITH CHECK
-- ❌ Allows users to insert data for any workspace
CREATE POLICY "Users create documents"
ON documents FOR INSERT
TO authenticated
WITH CHECK (true); -- No validation!
-- ✅ Restrict to user's workspaces
CREATE POLICY "Users create documents in their workspaces"
ON documents FOR INSERT
TO authenticated
WITH CHECK (
EXISTS (
SELECT 1 FROM workspace_members
WHERE workspace_members.workspace_id = documents.workspace_id
AND workspace_members.user_id = auth.uid()
)
);
Pitfall 5: Not Testing Policy Interactions
-- Multiple policies = ALL must pass
-- Policy 1: Must be workspace member
CREATE POLICY "p1" ON documents FOR SELECT
USING (workspace_id IN (SELECT workspace_id FROM workspace_members WHERE user_id = auth.uid()));
-- Policy 2: Document must not be archived
CREATE POLICY "p2" ON documents FOR SELECT
USING (is_archived = false);
-- Result: Users see documents that pass BOTH policies
-- If either fails, row is not returned
Real-World Applications
Case Study 1: SaaS Multi-Tenant Application
Requirements:
- 5,000 organizations (workspaces)
- 100,000 users
- Complete data isolation
- Role-based permissions
Implementation:
-- Core schema (simplified)
CREATE TABLE organizations (id uuid PRIMARY KEY, name text);
CREATE TABLE org_members (org_id uuid, user_id uuid, role text, PRIMARY KEY (org_id, user_id));
CREATE TABLE projects (id uuid PRIMARY KEY, org_id uuid, name text);
CREATE TABLE tasks (id uuid PRIMARY KEY, project_id uuid, title text, assigned_to uuid);
-- Enable RLS
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
ALTER TABLE org_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
-- Organization policies
CREATE POLICY "org_select" ON organizations FOR SELECT TO authenticated
USING (EXISTS (SELECT 1 FROM org_members WHERE org_id = id AND user_id = auth.uid()));
-- Project policies
CREATE POLICY "project_select" ON projects FOR SELECT TO authenticated
USING (EXISTS (SELECT 1 FROM org_members WHERE org_id = projects.org_id AND user_id = auth.uid()));
-- Task policies (users see tasks in their org's projects)
CREATE POLICY "task_select" ON tasks FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM projects
JOIN org_members ON org_members.org_id = projects.org_id
WHERE projects.id = tasks.project_id
AND org_members.user_id = auth.uid()
)
);
-- Indexes for performance
CREATE INDEX idx_org_members_user ON org_members(user_id, org_id);
CREATE INDEX idx_projects_org ON projects(org_id);
CREATE INDEX idx_tasks_project ON tasks(project_id);
Performance: Queries return in <50ms with proper indexes, even with millions of rows.
Case Study 2: Healthcare Application (HIPAA Compliance)
Requirements:
- Patient data isolation
- Doctor-patient relationships
- Audit logging
- HIPAA compliance
Implementation:
-- Patients table
CREATE TABLE patients (
id uuid PRIMARY KEY,
medical_record_number text UNIQUE,
name text,
date_of_birth date
);
-- Doctor-patient relationships
CREATE TABLE patient_access (
patient_id uuid REFERENCES patients(id),
doctor_id uuid REFERENCES auth.users(id),
access_level text CHECK (access_level IN ('full', 'read_only')),
granted_at timestamptz DEFAULT now(),
PRIMARY KEY (patient_id, doctor_id)
);
-- Medical records
CREATE TABLE medical_records (
id uuid PRIMARY KEY,
patient_id uuid REFERENCES patients(id),
record_type text,
content jsonb,
created_by uuid REFERENCES auth.users(id),
created_at timestamptz DEFAULT now()
);
-- Enable RLS
ALTER TABLE patients ENABLE ROW LEVEL SECURITY;
ALTER TABLE patient_access ENABLE ROW LEVEL SECURITY;
ALTER TABLE medical_records ENABLE ROW LEVEL SECURITY;
-- Policy: Doctors see only their assigned patients
CREATE POLICY "doctor_patient_access" ON patients FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM patient_access
WHERE patient_access.patient_id = patients.id
AND patient_access.doctor_id = auth.uid()
)
);
-- Policy: Doctors see medical records for their patients
CREATE POLICY "doctor_records_access" ON medical_records FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM patient_access
WHERE patient_access.patient_id = medical_records.patient_id
AND patient_access.doctor_id = auth.uid()
)
);
-- Policy: Only doctors with full access can create records
CREATE POLICY "doctor_create_records" ON medical_records FOR INSERT TO authenticated
WITH CHECK (
EXISTS (
SELECT 1 FROM patient_access
WHERE patient_access.patient_id = medical_records.patient_id
AND patient_access.doctor_id = auth.uid()
AND patient_access.access_level = 'full'
)
);
-- Audit trail (every access logged)
CREATE TABLE access_log (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid,
patient_id uuid,
action text,
timestamp timestamptz DEFAULT now()
);
-- Function to log access (called automatically)
CREATE OR REPLACE FUNCTION log_patient_access()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO access_log (user_id, patient_id, action)
VALUES (auth.uid(), NEW.patient_id, TG_OP);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER log_medical_record_access
AFTER INSERT OR UPDATE ON medical_records
FOR EACH ROW EXECUTE FUNCTION log_patient_access();
Compliance: RLS ensures zero-trust security—even compromised application code cannot bypass access controls.
Conclusion
Row Level Security is Postgres’s superpower for building secure multi-tenant applications. By moving authorization from application code to database policies, you gain:
✅ Defense in depth: Security enforced at database level, immune to application bugs ✅ Consistency: One source of truth for authorization rules ✅ Auditability: Clear policies that can be reviewed and tested ✅ Performance: Optimized with indexes, policies execute in microseconds
The RLS Production Checklist:
- ✅ Enable RLS on all tables immediately after creation
- ✅ Default deny: Table with RLS enabled = no access by default
- ✅ Use EXISTS instead of IN for membership checks
- ✅ Add indexes to support policy conditions
- ✅ Separate policies by operation (SELECT, INSERT, UPDATE, DELETE)
- ✅ Test with different roles (authenticated, anon, service_role)
- ✅ Monitor query performance with EXPLAIN ANALYZE
- ✅ Document policies with comments
- ✅ Never expose service role key to clients
- ✅ Combine RLS (tenant isolation) with application logic (business rules)
Performance Expectations:
| Setup | Query Time (P95) | Scalability |
|---|---|---|
| No indexes | 500-2000ms | Poor |
| Basic indexes | 50-200ms | Good |
| Optimized indexes + EXISTS | 10-50ms | Excellent |
| With caching layer | 1-10ms | Outstanding |
RLS is not optional for production applications—it’s foundational. Implement it correctly from day one, and you’ll have security that scales effortlessly from 10 users to 10 million.
Comments
Comments section will be integrated here. Popular options include Disqus, Utterances, or custom comment systems.