Supabase Row Level Security Guide 2026
Master RLS policies in Supabase. Real error patterns, production code, and security best practices for indie hackers.
TL;DR
Supabase Row Level Security (RLS) restricts database access at the row level using PostgreSQL policies. Enable it per table, write policies that reference auth.uid(), and test thoroughly—misconfigured RLS is a common source of security leaks and silent failures in production.
---
What Is Row Level Security?
Row Level Security (RLS) is a PostgreSQL feature that Supabase exposes directly. Instead of controlling access at the application layer, RLS moves the security boundary to the database itself. Each row has invisible rules: "only user X can read this" or "only admins can update this."
This matters because:
---
Enable RLS on a Table
First, create a table (or alter an existing one):
```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 NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP );
-- Enable RLS ALTER TABLE posts ENABLE ROW LEVEL SECURITY; ```
When RLS is enabled with no policies, all queries return zero rows. This is intentional and a common first error.
---
Common Error: "SELECT 0 rows"
Console Error: ``` Error: No rows returned in a non-0 result expected ```
Root Cause: RLS enabled, but no matching policy exists.
Fix: Write a policy:
```sql CREATE POLICY "Users can read own posts" ON posts FOR SELECT USING (auth.uid() = user_id); ```
Now SELECT * FROM posts WHERE user_id = <current_user_id> returns rows.
---
Policy Syntax Breakdown
A policy has five parts:
```sql CREATE POLICY "policy_name" ON table_name FOR operation -- SELECT, INSERT, UPDATE, DELETE, or ALL USING (expression) -- For SELECT/UPDATE/DELETE: row must pass this WITH CHECK (expression); -- For INSERT/UPDATE: new row must pass this ```
Example: Multi-operation policy
```sql -- Users insert their own posts CREATE POLICY "Users manage own posts" ON posts FOR INSERT WITH CHECK (auth.uid() = user_id);
-- Users update their own posts CREATE POLICY "Users update own posts" ON posts FOR UPDATE USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);
-- Users delete their own posts CREATE POLICY "Users delete own posts" ON posts FOR DELETE USING (auth.uid() = user_id); ```
---
Real-World Policy Examples
Public read, private write:
```sql CREATE POLICY "Public posts are readable" ON posts FOR SELECT USING (is_published = true);
CREATE POLICY "Users read own drafts" ON posts FOR SELECT USING (is_published = false AND auth.uid() = user_id); ```
Team access with role check:
```sql CREATE TABLE team_documents ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, team_id BIGINT NOT NULL, content TEXT, created_by UUID NOT NULL REFERENCES auth.users(id) );
ALTER TABLE team_documents ENABLE ROW LEVEL SECURITY;
CREATE TABLE team_members ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, team_id BIGINT NOT NULL, user_id UUID NOT NULL REFERENCES auth.users(id), role TEXT CHECK (role IN ('viewer', 'editor', 'admin')), UNIQUE(team_id, user_id) );
CREATE POLICY "Team members access docs" ON team_documents FOR SELECT USING ( EXISTS ( SELECT 1 FROM team_members WHERE team_members.team_id = team_documents.team_id AND team_members.user_id = auth.uid() ) );
CREATE POLICY "Only editors can update" ON team_documents FOR UPDATE USING ( EXISTS ( SELECT 1 FROM team_members WHERE team_members.team_id = team_documents.team_id AND team_members.user_id = auth.uid() AND team_members.role IN ('editor', 'admin') ) ) WITH CHECK ( EXISTS ( SELECT 1 FROM team_members WHERE team_members.team_id = team_documents.team_id AND team_members.user_id = auth.uid() AND team_members.role IN ('editor', 'admin') ) ); ```
---
Testing RLS Policies
Error: "permission denied for schema public"
This means you're signed out or the policy check failed.
Test with Supabase client (verify in official docs for latest SDK version):
```javascript // As authenticated user const { data, error } = await supabase .from('posts') .select('*');
if (error) console.error('RLS blocked:', error.message); ```
Error: "new row violates row-level security policy"
Your INSERT/UPDATE data doesn't pass the WITH CHECK expression:
```sql -- This fails if you try to INSERT with a different user_id INSERT INTO posts (user_id, title, content) VALUES ('different-uuid', 'Title', 'Content'); -- Error: new row violates row-level security policy "Users manage own posts" ```
---
Performance Considerations
auth.uid() often, index the foreign key column:EXISTS are okay, but avoid loops. Use EXISTS instead of COUNT(*).---
Bypassable Only by Service Role
RLS policies apply to all authenticated users. To bypass RLS (for admin scripts), use the service role key with the Supabase client:
```javascript const adminClient = createClient(url, SERVICE_ROLE_KEY); // This bypasses RLS const { data } = await adminClient.from('posts').select('*'); ```
Never expose SERVICE_ROLE_KEY in client code.
---
Debugging Checklist
1. Is RLS enabled? Check: ALTER TABLE table_name ENABLE ROW LEVEL SECURITY;
2. Does a policy exist for your operation? SELECT * FROM pg_policies WHERE tablename = 'table_name';
3. Is auth.uid() set? Sign in first—auth.uid() is NULL for anonymous users.
4. Does the policy expression pass for your data? Test manually: SELECT auth.uid() = user_id;
5. Are you using the anon key (respects RLS) or service role key (bypasses RLS)?
---
Recommended Resources
---
What am I missing?
Did I skip something critical? Have you hit an RLS error pattern not covered here? Drop corrections, questions, and real-world edge cases in the comments. Indie hackers implementing their first security layer often discover gaps—let's fix them together.