Supabase Row Level Security: Beginner Guide 2026
Master RLS in Supabase with production-ready patterns, real error messages, and exact setup steps for securing your data at the database level.
TL;DR
Supabase Row Level Security (RLS) enforces data access rules directly in PostgreSQL. Enable RLS on tables, create policies matching your auth model, and test thoroughly—common mistakes include forgetting to enable RLS globally, mixing up auth.uid() with custom claims, and policies that silently return zero rows instead of throwing errors.
---
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 wraps this with its auth system, giving you:
Without RLS, anyone with valid JWT tokens can query any row. With RLS, a user sees only rows their policy allows.
---
Prerequisites
---
Step 1: Enable RLS on Your Table
Create a test table first:
```sql CREATE TABLE posts ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, title TEXT NOT NULL, content TEXT, created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL ); ```
Now enable RLS:
```sql ALTER TABLE posts ENABLE ROW LEVEL SECURITY; ```
Critical: Without this, policies won't take effect. Forgetting this is the #1 beginner mistake.
---
Step 2: Create Your First Policy
Users should see only their own posts:
```sql CREATE POLICY "Users can view own posts" ON posts FOR SELECT USING (auth.uid() = user_id); ```
Breakdown:
Users can also insert their own posts:
```sql CREATE POLICY "Users can create posts" ON posts FOR INSERT WITH CHECK (auth.uid() = user_id); ```
Note: INSERT and UPDATE use WITH CHECK instead of USING.
Users can update their own posts:
```sql CREATE POLICY "Users can update own posts" ON posts FOR UPDATE USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id); ```
Users can delete their own posts:
```sql CREATE POLICY "Users can delete own posts" ON posts FOR DELETE USING (auth.uid() = user_id); ```
---
Step 3: Allow Public Read Access
Let anyone read published posts:
```sql CREATE POLICY "Anyone can view published posts" ON posts FOR SELECT USING (is_published = true);
-- Don't forget to add the column first: ALTER TABLE posts ADD COLUMN is_published BOOLEAN DEFAULT false; ```
Policies are additive—a user needs to match *at least one* SELECT policy to read a row.
---
Common Error Messages You'll See
Error 1: Policy Not Enforced
``` PolicyViolationException: new row violates row-level security policy ```
Cause: RLS is enabled, but your policy's WITH CHECK condition failed on INSERT/UPDATE.
Example: ```javascript const { data, error } = await supabase .from('posts') .insert([{ title: 'Test', user_id: 'wrong-uuid' }]); // Error because INSERT policy checks auth.uid() = user_id ```
Fix: Always use the current user's ID from supabase.auth.getSession().
Error 2: Silent Empty Results
``` // No error thrown, but results.data = [] const { data } = await supabase.from('posts').select(); console.log(data); // [] ```
Cause: User matched no SELECT policies; RLS silently returns empty set. This is *not* an error—it's correct behavior but confusing to debug.
Fix: Check your policy logic. Does the current user's ID match any row's user_id? Is is_published actually true? Add logging:
```sql -- Debug: See all policies on a table SELECT schemaname, tablename, policyname, qual, with_check FROM pg_policies WHERE tablename = 'posts'; ```
Error 3: Missing auth.uid()
``` function auth.uid() does not exist ```
Cause: Using Supabase without the auth extension or calling from wrong context.
Fix: Verify in SQL editor that SELECT auth.uid(); returns a UUID. If it returns NULL, check that your JWT token is valid in the current session.
---
Advanced Pattern: Admin Bypass
Admins should see all posts. Add an is_admin flag to your users table:
```sql ALTER TABLE auth.users ADD COLUMN is_admin BOOLEAN DEFAULT false;
CREATE POLICY "Admins can view all posts" ON posts FOR SELECT USING ( EXISTS ( SELECT 1 FROM auth.users WHERE auth.users.id = auth.uid() AND auth.users.is_admin = true ) ); ```
Warning: Never trust client-provided claims for security-critical checks. Always verify in the database.
---
Testing Your Policies
1. Via SQL Editor - Use "Change User" dropdown to impersonate users 2. Via JavaScript Client - Create multiple auth sessions and test 3. Via curl with JWT tokens - Manually craft bearer tokens (advanced)
Production-ready test pattern:
```javascript // Test as User A const { data: userA } = await supabase.auth.signUp({ email: 'a@test.com' }); await supabase .from('posts') .insert([{ title: 'User A Post', user_id: userA.user.id }]);
// Sign in as User B await supabase.auth.signOut(); await supabase.auth.signInWithPassword({ email: 'b@test.com' });
// User B should see zero results const { data: posts } = await supabase.from('posts').select(); console.assert(posts.length === 0, 'User B leaked User A data!'); ```
---
Performance Considerations
---
References
---
What am I missing?
Have you hit RLS gotchas we didn't cover? Use cases for multi-tenant architectures? Better patterns for organization-based access? Drop your corrections and experiences in the comments—this guide improves with reader feedback.