Supabase Row Level Security: Beginner's Guide 2026
Learn RLS policies in Supabase with production-ready patterns, real error messages, and exact syntax to secure your PostgreSQL data.
TL;DR
Supabase Row Level Security (RLS) enforces database-level access control via PostgreSQL policies. Enable it per table, write policies matching your auth users to rows, and test thoroughly—misconfigured policies block all queries with cryptic errors.
---
What is Row Level Security?
Row Level Security is a PostgreSQL feature that restricts which rows a user can query, insert, update, or delete based on policies you define. Supabase exposes this through the dashboard and SQL, letting you move security logic from your application to the database.
Why it matters:
---
Enable RLS on a Table
RLS is disabled by default. Navigate to your table in the Supabase dashboard:
1. Go to Authentication → Policies 2. Select your table 3. Click Enable RLS
Or via SQL (PostgreSQL 9.5+):
```sql ALTER TABLE public.posts ENABLE ROW LEVEL SECURITY; ```
⚠️ Critical: Enabling RLS without policies blocks all queries for authenticated users. You'll see:
``` ERROR: new row violates row-level security policy for table "posts" ```
If using the Anon role (unauthenticated), you'll get:
``` ERROR: permission denied for schema public ```
---
Core Concepts
Auth Context
Supabase injects user data via auth.uid() (JWT subject) and auth.jwt() (full JWT claims). Use these in policy conditions:
```sql -- Access user's own posts CREATE POLICY "Users can view own posts" ON public.posts FOR SELECT USING (auth.uid() = user_id); ```
Policy Anatomy
```sql CREATE POLICY policy_name ON table_name FOR operation -- SELECT, INSERT, UPDATE, DELETE, or ALL TO role_name -- authenticated, anon, or custom role USING (expression) -- For SELECT/UPDATE/DELETE WITH CHECK (expression); -- For INSERT/UPDATE ```
USING: Filters which rows the user sees (SELECT, DELETE, UPDATE read-side) WITH CHECK: Validates data before insert/update (write-side)
---
Real-World Patterns
Pattern 1: Personal Data
Users access only their own records:
```sql CREATE POLICY "Users can manage own profile" ON public.profiles FOR ALL USING (auth.uid() = id) WITH CHECK (auth.uid() = id); ```
Pattern 2: Public + Private Rows
Everyone reads public posts; owners modify theirs:
```sql CREATE POLICY "Posts are viewable by everyone" ON public.posts FOR SELECT USING (is_public = true OR auth.uid() = user_id);
CREATE POLICY "Users can modify own posts" ON public.posts FOR UPDATE USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id); ```
Pattern 3: Team-Based Access
Users access rows where they're team members (requires junction table):
```sql CREATE POLICY "Team members can view projects" ON public.projects FOR SELECT USING ( EXISTS ( SELECT 1 FROM public.team_members WHERE team_id = projects.team_id AND user_id = auth.uid() ) ); ```
Pattern 4: Admin Bypass
Admins see everything (custom JWT claim):
```sql CREATE POLICY "Admins can see all" ON public.posts FOR SELECT USING ( auth.uid() = user_id OR (auth.jwt() ->> 'role') = 'admin' ); ```
Verify your JWT includes custom claims in [Supabase Auth docs](https://supabase.com/docs/guides/auth/jwt).
---
Common Errors & Debugging
Error 1: Policy Silently Denies Query
``` ERROR: new row violates row-level security policy for table "posts" DETAIL: Failing row contains (1, 'Title', user-uuid, 2026-01-15). ```
Cause: WITH CHECK expression returned false.
Fix: Check auth.uid() is being set correctly:
```javascript const { data, error } = await supabase.auth.getUser(); console.log('Current UID:', data.user.id); ```
Error 2: Missing FROM Clause in Policy
``` ERROR: missing FROM-clause entry for table "other_table" ```
Cause: Policy references a table without explicit JOIN/EXISTS. Fix: Use EXISTS subquery:
```sql -- ❌ Wrong WHERE team_id = team_members.team_id
-- ✅ Right WHERE EXISTS ( SELECT 1 FROM team_members WHERE team_id = posts.team_id AND user_id = auth.uid() ) ```
Error 3: Anon Role Cannot Access
``` ERROR: permission denied for schema public ```
Cause: Public data needs explicit policy for anon role.
Fix: Create policy granting anon SELECT on public rows:
```sql CREATE POLICY "Anonymous can view public posts" ON public.posts FOR SELECT TO anon USING (is_public = true); ```
---
Testing Policies
In Supabase Dashboard
Go to SQL Editor and execute as different roles:
```sql -- Test as user SET request.jwt.claims = '{"sub": "your-user-uuid"}'; SELECT * FROM posts;
-- Test as anon RESET request.jwt.claims; SELECT * FROM posts; ```
In Your App
```javascript // Test as authenticated user const { data, error } = await supabase .from('posts') .select('*');
if (error) { console.error('RLS denied:', error.message); // Likely: "new row violates row-level security policy" }
// Test as anon const anonClient = supabase.createClient(URL, ANON_KEY); const { data: anonData } = await anonClient .from('posts') .select('*'); ```
---
Production Checklist
---
Performance Notes
RLS policies run per-row. Complex nested EXISTS subqueries can slow large queries. Optimize with:
1. Indexes on join columns: ```sql CREATE INDEX idx_team_members_user ON team_members(user_id); ```
2. Denormalize critical paths if RLS logic is too complex
3. Monitor slow queries in your Postgres logs
---
Learn More
---
What am I missing?
RLS is powerful but easy to misconfigure silently. Drop a comment if you've hit edge cases like:
I'll update this guide based on your experiences.