Supabase Row Level Security Guide 2026
Master RLS policies to secure your database. Production patterns, real errors, and step-by-step setup for indie hackers.
TL;DR
Supabase Row Level Security (RLS) enforces database-level access control. Enable it on tables, write policies matching your auth user, and test thoroughly. Common pitfall: forgetting to enable RLS or writing overly permissive policies. Start with deny-by-default, add specific allow rules.
---
What is Row Level Security?
Row Level Security is PostgreSQL's native feature that restricts which rows users can access based on policies you define. Supabase makes this accessible through the dashboard and API. Unlike application-layer security, RLS is enforced at the database level—meaning even direct SQL queries respect it.
Why it matters: A leaked API key or compromised frontend code can't bypass RLS policies. It's your last line of defense.
Prerequisites
Step 1: Enable RLS on Your Table
```sql -- Create a sample notes table CREATE TABLE public.notes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, title TEXT NOT NULL, content TEXT, created_at TIMESTAMP DEFAULT now(), updated_at TIMESTAMP DEFAULT now() );
-- Enable RLS ALTER TABLE public.notes ENABLE ROW LEVEL SECURITY; ```
Critical: Enabling RLS without policies blocks all access (including your admin user). You'll see:
``` ERROR: new row violates row-level security policy for table "notes" ```
Immediately write policies after enabling RLS.
Step 2: Write Your First Policy
Start with a deny-by-default approach. Users can only see their own rows:
```sql -- Policy: Users can SELECT their own notes CREATE POLICY "Users can view own notes" ON public.notes FOR SELECT USING (auth.uid() = user_id);
-- Policy: Users can INSERT their own notes CREATE POLICY "Users can insert own notes" ON public.notes FOR INSERT WITH CHECK (auth.uid() = user_id);
-- Policy: Users can UPDATE their own notes CREATE POLICY "Users can update own notes" ON public.notes FOR UPDATE USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);
-- Policy: Users can DELETE their own notes CREATE POLICY "Users can delete own notes" ON public.notes FOR DELETE USING (auth.uid() = user_id); ```
Explanation:
USING: Filters which rows are accessible (SELECT, DELETE)WITH CHECK: Validates new/updated data (INSERT, UPDATE)auth.uid(): Returns the current authenticated user's UUIDStep 3: Test Your Policies
Using Supabase Dashboard
1. Navigate to SQL Editor 2. Click "Impersonate user" (available for authenticated users) 3. Run queries to verify access
Using JavaScript Client
```javascript import { createClient } from '@supabase/supabase-js';
const supabase = createClient(URL, ANON_KEY);
// After user signs in const { data, error } = await supabase .from('notes') .select('*');
if (error) { console.error('RLS Policy Error:', error.message); // Common error: // "Failed to retrieve data: new row violates row-level security policy" } ```
Common Error Messages
Error 1: No policies defined ``` ERROR: 42501: new row violates row-level security policy for table "notes" ``` Fix: Write at least one SELECT policy before querying.
Error 2: Incorrect auth.uid() usage
```
ERROR: function auth.uid() does not exist
```
Fix: Only works with Supabase auth. Verify JWT contains sub claim representing user ID.
Error 3: Stale JWT token
```
Unauthorized: JWT expired
```
Fix: Token expires (default 1 hour). Refresh using supabase.auth.refreshSession().
Advanced Pattern: Public + Private Rows
Share some data publicly while keeping private content restricted:
```sql ALTER TABLE public.notes ADD COLUMN is_public BOOLEAN DEFAULT FALSE;
-- Public rows visible to anyone CREATE POLICY "Anyone can view public notes" ON public.notes FOR SELECT USING (is_public = TRUE);
-- Users see their own notes regardless of public status CREATE POLICY "Users can view own notes regardless of status" ON public.notes FOR SELECT USING (auth.uid() = user_id); ```
Policies are OR'd together—user sees rows matching ANY policy.
Advanced Pattern: Team/Organization Access
```sql -- Create teams table CREATE TABLE public.teams ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), owner_id UUID NOT NULL REFERENCES auth.users(id), name TEXT NOT NULL );
-- Create team members junction table CREATE TABLE public.team_members ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), team_id UUID NOT NULL REFERENCES public.teams(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, UNIQUE(team_id, user_id) );
-- Notes now reference teams ALTER TABLE public.notes ADD COLUMN team_id UUID REFERENCES public.teams(id);
-- Policy: Users can see notes from their teams CREATE POLICY "Users can view team notes" ON public.notes FOR SELECT USING ( team_id IN ( SELECT team_id FROM public.team_members WHERE user_id = auth.uid() ) ); ```
Debugging Tips
Enable query logging to understand which policies reject queries:
```sql -- Check policy definitions SELECT tablename, policyname, cmd, qual, with_check FROM pg_policies WHERE tablename = 'notes'; ```
Test without auth (use service role key—never expose in frontend):
```javascript const adminClient = createClient(URL, SERVICE_ROLE_KEY);
// Bypasses RLS—use only for admin operations const { data } = await adminClient .from('notes') .select('*'); ```
Production Checklist
See Also
What am I missing?
Have you hit RLS edge cases? Spotted inaccuracies? Supabase updates frequently—share version numbers, error messages, and patterns that worked for your team. Comment below.