Supabase Row Level Security Guide 2026
Master RLS policies to secure your PostgreSQL data. Production-ready patterns, common errors, and exact implementation steps.
TL;DR
Supabase Row Level Security (RLS) enforces database-level access control automatically. Enable it on tables, write policies defining who sees what, and test thoroughly—it's the foundation of secure multi-tenant apps. Verify current policy limits in [official Supabase docs](https://supabase.com/docs/guides/auth/row-level-security).
What is Row Level Security?
RLS is PostgreSQL's built-in feature that filters database rows before they're returned to your application. Instead of handling authorization in code, the database enforces it—eliminating security bugs from forgotten checks.
Why this matters:
Enable RLS on Your Table
RLS is disabled by default. First, turn it on:
```sql ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY; ```
Now *no one* can read, insert, or update rows—even as the table owner—until you create explicit policies. This is intentionally restrictive.
Core Policy Pattern: Owner-Based Access
Most apps follow this: users own their data, and RLS ensures they only see their own rows.
```sql -- Create a policy allowing users to read only their own documents CREATE POLICY "Users can read their own documents" ON public.documents FOR SELECT USING (auth.uid() = user_id);
-- Create a policy allowing users to insert their own documents CREATE POLICY "Users can insert their own documents" ON public.documents FOR INSERT WITH CHECK (auth.uid() = user_id);
-- Create a policy allowing users to update their own documents CREATE POLICY "Users can update their own documents" ON public.documents FOR UPDATE USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);
-- Create a policy allowing users to delete their own documents CREATE POLICY "Users can delete their own documents" ON public.documents FOR DELETE USING (auth.uid() = user_id); ```
Key components:
auth.uid() – Supabase function returning the authenticated user's UUIDUSING clause – filters which rows can be accessedWITH CHECK – filters which rows can be modified (INSERT/UPDATE)FOR SELECT/INSERT/UPDATE/DELETE – operation-specific policiesTeam-Based Access (Shared Workspaces)
For collaborative apps where users belong to teams:
```sql -- Assume tables: teams, team_members, documents
CREATE POLICY "Users can read docs in their teams" ON public.documents FOR SELECT USING ( EXISTS ( SELECT 1 FROM public.team_members WHERE team_members.team_id = documents.team_id AND team_members.user_id = auth.uid() ) );
CREATE POLICY "Users can insert docs in their teams" ON public.documents FOR INSERT WITH CHECK ( EXISTS ( SELECT 1 FROM public.team_members WHERE team_members.team_id = documents.team_id AND team_members.user_id = auth.uid() ) ); ```
This pattern scales to any membership model—roles, permissions, hierarchies, etc.
Real Error Messages You'll See
Error 1: Policy prevents all access
```
PEQ: new row violates row-level security policy "Users can read their own documents" for table "documents"
```
This means your USING or WITH CHECK clause evaluated to false. Check that auth.uid() matches the row's user_id.
Error 2: No policies exist ``` ERROR: permission denied for table documents ``` You enabled RLS but forgot to create any policies. The database blocks all access.
Error 3: Recursive policy check
```
ERROR: infinite recursion detected in policy for relation "documents"
```
A policy references the same table in its subquery without proper conditions. Add NOT MATERIALIZED or restructure the logic.
Testing Policies (Critical)
Never deploy RLS changes without testing. Use Supabase's SQL editor:
```sql -- Test as a specific user SET request.jwt.claim.sub = 'user-uuid-here'; SELECT * FROM documents; -- Should return only that user's rows
-- Reset to bypass RLS (superuser) RESET request.jwt.claim.sub; SELECT * FROM documents; -- Should return all rows ```
Or test from your app:
```javascript const { data, error } = await supabase .from('documents') .select('*');
if (error?.code === 'PGRST116') { console.error('RLS policy denied access:', error.message); } else { console.log('User can access:', data); } ```
Common Pitfalls
1. Forgetting to enable RLS on joined tables If documents reference projects, enable RLS on *both* tables and write policies for each.
2. Over-complex policies Avoid deeply nested subqueries. Use [materialized views](/?guide=postgresql-views) for complex checks.
3. Assuming RLS catches everything RLS controls *which rows* users see—it doesn't encrypt data or hide column names. Use column-level security for sensitive fields.
4. Not testing with real JWT tokens The Supabase dashboard bypasses RLS. Always test client-side with actual authentication.
Performance Notes
RLS adds overhead on every query. For tables with >100K rows and complex policies:
user_id, team_id, etc.)EXPLAIN ANALYZEVerify performance expectations in [Supabase performance docs](https://supabase.com/docs/guides/database/performance-tuning).
Anon Key Limitations
Supabase provides two keys:
auth.uid() IS NULLNever expose the service role key. Use it only in backend code.
Production Checklist
What am I missing?
Did I overlook a critical RLS pattern? Encountered an error message not covered? Have questions about integrating RLS with [authentication flows](/?guide=supabase-auth-patterns)? Drop corrections and additions in the comments—this guide improves with community input.
Resources: