Supabase Row Level Security Guide 2026
Master Supabase RLS policies to secure user data at the database level. Real examples, common errors, and production patterns.
TL;DR
Row Level Security (RLS) in Supabase enforces data access rules directly in PostgreSQL. Enable it per table, write policies using SQL expressions, and test thoroughly before production. Most mistakes involve missing auth claims or overly permissive policies.
---
What is Row Level Security?
Row Level Security is a PostgreSQL feature that restricts which rows a user can access based on policies you define. Supabase makes this accessible through its dashboard and API.
Instead of filtering data in your application code: ```typescript // ❌ Risky - filtering happens in app const { data } = await supabase .from('posts') .select('*') .eq('user_id', userId); // What if userId is spoofed? ```
You enforce rules at the database layer: ```sql -- ✅ Secure - enforced by PostgreSQL CREATE POLICY "users_can_read_own_posts" ON posts FOR SELECT USING (auth.uid() = user_id); ```
---
Enable RLS on a Table
First, enable RLS on the table:
```sql ALTER TABLE posts ENABLE ROW LEVEL SECURITY; ```
Important: Enabling RLS without policies blocks *all* access by default. You must create policies immediately or your app breaks.
Common Error #1:
``` PolicyError: row level security policy denied access ``` This means either: 1. No policy exists for your operation (SELECT, INSERT, UPDATE, DELETE) 2. Your auth context doesn't satisfy the policy condition 3. The policy'sTO clause excludes your role---
Basic Policy Patterns
Pattern 1: Users Read Their Own Data
```sql CREATE POLICY "users_read_own_posts" ON posts FOR SELECT USING (auth.uid() = user_id); ```
Breakdown:
FOR SELECT - applies only to readsUSING - the filter condition (what rows are visible)auth.uid() - built-in Supabase function returning the JWT's sub claimuser_id - your table's foreign key columnPattern 2: Authenticated Users Insert, Own Rows Only
```sql CREATE POLICY "users_insert_own_posts" ON posts FOR INSERT WITH CHECK (auth.uid() = user_id); ```
Key difference:
WITH CHECK for INSERT/UPDATE - validates data being writtenuser_id; the policy enforces it matches authPattern 3: Public Read, Private Write
```sql CREATE POLICY "posts_public_read" ON posts FOR SELECT USING (true); -- Everyone can read
CREATE POLICY "posts_owner_write" ON posts FOR UPDATE USING (auth.uid() = user_id); ```
---
Advanced: Roles and Sharing
For multi-user collaboration (team projects, shared documents):
```sql CREATE TABLE documents ( id uuid PRIMARY KEY, owner_id uuid NOT NULL, title text );
CREATE TABLE document_access ( document_id uuid NOT NULL, user_id uuid NOT NULL, access_level text, -- 'read' or 'write' PRIMARY KEY (document_id, user_id) );
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY "owner_full_access" ON documents FOR ALL USING (auth.uid() = owner_id);
CREATE POLICY "shared_access" ON documents FOR SELECT USING ( EXISTS ( SELECT 1 FROM document_access WHERE document_access.document_id = documents.id AND document_access.user_id = auth.uid() ) ); ```
This uses EXISTS to check if the user appears in your access control table.
---
Testing RLS Policies
In Supabase Dashboard
Use the SQL editor with SET ROLE to simulate users:
```sql SET ROLE authenticated; -- Simulate logged-in user SET app.jwt_claims = jsonb_build_object('sub', '12345');
SELECT * FROM posts; -- Respects RLS policies ```
In Your Application (TypeScript/JavaScript)
```typescript import { createClient } from '@supabase/supabase-js';
const supabase = createClient(url, anonKey);
// Sign in user await supabase.auth.signInWithPassword({ email: 'user@example.com', password: 'password123' });
// This request includes the auth token // PostgreSQL sees auth.uid() from the JWT const { data, error } = await supabase .from('posts') .select('*');
if (error?.code === 'PGRST116') { console.error('RLS denied access:', error.message); } ```
Common Error #2:
``` JWT claims missing or invalid ``` Ensure: 1. User is authenticated (checkauth.currentUser)
2. Auth token is sent with the request (Supabase client does this automatically)
3. Your policy doesn't assume custom claims that don't exist---
Production Checklist
✅ Enable RLS on all user-facing tables ```sql SELECT tablename FROM pg_tables WHERE schemaname = 'public'; -- Then run ALTER TABLE X ENABLE ROW LEVEL SECURITY for each ```
✅ Test policies with real user tokens
Never assume auth.uid() works without testing cross-origin requests.
✅ Verify policy performance
Complex EXISTS subqueries can be slow. Check query plans:
```sql
EXPLAIN ANALYZE SELECT * FROM documents;
```
✅ Audit policy changes Log who modified policies using triggers or Supabase audit logs.
✅ Handle policy bypass scenarios Service role keys (backend-only) bypass RLS. Use them only for admin operations: ```typescript // ✅ Backend only const admin = createClient(url, serviceRoleKey); await admin.from('users').update({ banned: true }).eq('id', userId); ```
---
Common Error #3:
``` ForeignKeyViolation: insert or update on table violates foreign key ``` Often combined with RLS—the row your policy allows to insert references a row you can't see. Solution: ```sql CREATE POLICY "can_insert_if_owner_of_parent" ON posts FOR INSERT WITH CHECK ( auth.uid() = user_id AND EXISTS (SELECT 1 FROM projects WHERE id = project_id AND owner_id = auth.uid()) ); ```---
Helpful Resources
---
What am I missing?
Have you hit RLS issues I didn't cover? Comment below with:
This guide improves with your real-world feedback.