Supabase Row Level Security Guide 2026
Master RLS policies in Supabase to control database access at the row level. Production patterns, common errors, and exact setup steps.
TL;DR
Row Level Security (RLS) in Supabase enforces access control directly in PostgreSQL. Enable it on tables, write policies using auth.uid() and auth.jwt(), and test thoroughly—misconfigured RLS either blocks legitimate requests or exposes sensitive data.
What is Row Level Security?
RLS is a PostgreSQL feature that Supabase exposes through its dashboard and API. Instead of controlling access in your application layer, policies live in the database and execute for *every* query, regardless of how the request reaches your database. This is defense-in-depth: even if your app logic has a bug, RLS policies catch it.
Why it matters for indie hackers:
Enabling RLS: Step by Step
1. Turn on RLS for a Table
In the Supabase dashboard:
1. Navigate to SQL Editor or Tables
2. Click the table (e.g., posts)
3. Click RLS toggle to On
Or via SQL (Supabase v0.47.0+, [verify in official docs](https://supabase.com/docs/guides/database/postgres/row-level-security)):
```sql ALTER TABLE posts ENABLE ROW LEVEL SECURITY; ```
Critical: Once RLS is enabled, *all* queries are blocked unless a policy explicitly allows them. This includes your own admin queries.
2. Create Your First Policy
Scenario: Users can only see their own posts.
```sql CREATE POLICY "Users can view their own posts" ON posts FOR SELECT USING (auth.uid() = user_id); ```
Breakdown:
FOR SELECT: applies to read queries onlyUSING: the condition that must be trueauth.uid(): built-in Supabase function returning the authenticated user's UUIDuser_id: column in your posts table3. Add Write Permissions
Users should also *insert* their own posts:
```sql CREATE POLICY "Users can insert their own posts" ON posts FOR INSERT WITH CHECK (auth.uid() = user_id); ```
Use WITH CHECK for INSERT/UPDATE (it validates the row being written) and USING for SELECT/DELETE.
Production Patterns
Multi-Tenant Apps
```sql -- posts table with organization context ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
CREATE POLICY "org_members_read_posts" ON posts FOR SELECT USING ( org_id IN ( SELECT org_id FROM org_members WHERE user_id = auth.uid() ) );
CREATE POLICY "org_members_create_posts" ON posts FOR INSERT WITH CHECK ( org_id IN ( SELECT org_id FROM org_members WHERE user_id = auth.uid() ) ); ```
This pattern: users see posts only in organizations they're members of.
Admin Bypass with Custom Claims
Store admin status in JWT claims (via Supabase Auth or [custom tokens](/?guide=supabase-custom-claims)):
```sql CREATE POLICY "admins_bypass_rls" ON posts FOR SELECT USING ( auth.uid() = user_id OR auth.jwt() ->> 'role' = 'admin' ); ```
Warning: auth.jwt() ->> 'role' only works if your auth provider or custom claims setter actually includes that field. Verify with:
```javascript // In your client const { data } = await supabase.auth.getSession(); console.log(data.session.user.user_metadata); // check custom claims ```
Service Role for Admin Operations
For operations that bypass RLS (admin dashboards), use the service role key (never expose client-side):
```javascript // Server-side only const supabaseAdmin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
const { data } = await supabaseAdmin .from('posts') .select('*'); // ignores RLS, returns all rows ```
Common Errors and Fixes
Error 1: "Permission denied for schema public"
``` ERROR: permission denied for schema public Code: 42501 ```
Cause: RLS is enabled but no SELECT policy exists (even for your own requests).
Fix:
```sql CREATE POLICY "enable_read_for_authenticated_users" ON posts FOR SELECT USING (true); -- temporary: replace with proper policy ```
Error 2: "new row violates row-level security policy"
``` ERROR: new row violates row-level security policy for table "posts" Code: 42501 ```
Cause: INSERT/UPDATE policy's WITH CHECK condition failed.
Fix: Ensure the data being inserted matches your policy:
```javascript const { error } = await supabase .from('posts') .insert({ title: 'My post', user_id: (await supabase.auth.getUser()).data.user.id, // matches policy }); ```
Error 3: "column "user_id" must appear in the SELECT list"
``` ERROR: column "user_id" must appear in the SELECT list Code: 42P07 ```
Cause: Your USING clause references a column not returned in SELECT.
Fix: Either include the column or fetch it separately:
```sql -- Option 1: include user_id in response SELECT id, title, user_id FROM posts WHERE ...
-- Option 2: reference the column without selecting it -- (RLS evaluates before the SELECT list) ```
Testing RLS Policies
In Supabase Dashboard
1. SQL Editor → write a test query 2. Click Run (uses your logged-in session) 3. Switch to incognito mode and test as unauthenticated user
Programmatic Testing
```javascript import { createClient } from '@supabase/supabase-js';
const supabase = createClient(URL, ANON_KEY);
// Test 1: unauthenticated user (should fail) const { data: noAuth } = await supabase.from('posts').select('*'); console.assert(noAuth === null, 'RLS blocked unauthenticated access');
// Test 2: authenticated user (should succeed) await supabase.auth.signInWithPassword({ email, password }); const { data: withAuth } = await supabase.from('posts').select('*'); console.assert(withAuth.length >= 0, 'Authenticated user can read'); ```
Debugging Tips
Check active policies:
```sql SELECT * FROM pg_policies WHERE tablename = 'posts'; ```
Enable query logging (Supabase v0.50.0+, [verify current version](https://supabase.com/docs/reference/postgres/server-info)):
```sql ALTER TABLE posts SET (log_min_duration_statement = 0); ```
Then check logs in Postgres Logs tab of dashboard.
See Also
What am I missing?
Is there a specific RLS pattern you're implementing that isn't covered here? Questions about JWT claim validation, performance implications, or testing strategies? Drop them in the comments—I'll update this guide and give you credit.