Supabase Row Level Security Guide 2026 - Indie Hackers
Production-ready RLS patterns for Supabase. Learn policies, common errors, and security best practices with real code examples.
Supabase Row Level Security: A Beginner's Guide for 2026
TL;DR: Row Level Security (RLS) is Supabase's permission layer that enforces database rules at query time. Enable it on tables, write policies using PostgreSQL, and test with auth.uid() and auth.jwt() claims. Common mistakes: forgetting to enable RLS, policies that block INSERT, and testing without proper auth context.
---
What is Row Level Security?
Row Level Security (RLS) is a PostgreSQL feature that Supabase exposes for frontend developers. Instead of trusting your client app to enforce permissions, RLS moves security to the database itself. Every query—read, insert, update, delete—is filtered by policies before data returns.
Why this matters: if someone hijacks your auth token or exploits your client-side logic, the database *still* won't let them access rows they shouldn't see.
Enabling RLS: The First Step
RLS is disabled by default on new tables (verify in your Supabase project's Table Editor). To enable it:
```sql ALTER TABLE users ENABLE ROW LEVEL SECURITY; ALTER TABLE posts ENABLE ROW LEVEL SECURITY; ALTER TABLE comments ENABLE ROW LEVEL SECURITY; ```
Once enabled, all queries fail unless you write explicit policies. This is intentional—default deny is more secure than default allow.
Core Concepts: auth.uid() and JWT Claims
Supabase authenticates users and makes identity available in PostgreSQL:
auth.uid() — returns the UUID of the currently authenticated userauth.jwt() — returns the full JWT payload as a JSON objectcurrent_user_id — custom identifier you can set in JWT claimsYou reference these in policy expressions:
```sql -- User can only see their own posts CREATE POLICY "Users can view own posts" ON posts FOR SELECT USING (auth.uid() = user_id); ```
Writing Your First Policy
Here's a complete, production-ready example for a blogging app:
```sql -- Table setup CREATE TABLE posts ( 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 NOT NULL, published boolean DEFAULT false, created_at timestamptz DEFAULT now() );
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- Policy 1: Users can view published posts OR their own posts CREATE POLICY "view_posts" ON posts FOR SELECT USING ( published = true OR user_id = auth.uid() );
-- Policy 2: Users can only insert their own posts CREATE POLICY "insert_own_posts" ON posts FOR INSERT WITH CHECK (user_id = auth.uid());
-- Policy 3: Users can only update their own posts CREATE POLICY "update_own_posts" ON posts FOR UPDATE USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid());
-- Policy 4: Users can only delete their own posts CREATE POLICY "delete_own_posts" ON posts FOR DELETE USING (user_id = auth.uid()); ```
Key patterns:
USING for SELECT, DELETE (what can be seen/removed)WITH CHECK for INSERT, UPDATE (what values are allowed)OR for complex rulesReal Errors You'll See
When RLS blocks a query, Supabase returns one of these:
Error 1: New rows violate RLS policy on INSERT
``` PgError: new row violates row-level security policy "insert_own_posts" for table "posts" ``` Cause: You're trying to insert with auser_id that doesn't match auth.uid().
Fix: Verify your JWT is valid and the user_id in your payload matches the database.Error 2: Permission denied for relation
``` PgError: permission denied for relation posts ``` Cause: RLS is enabled but NO policies exist, or policies are too restrictive. Fix: CheckSELECT * FROM pg_policies WHERE tablename = 'posts'; to list policies. Ensure at least one SELECT policy exists for your use case.Error 3: Query returned no rows when one was expected
``` JavaScript: TypeError: Cannot read property 'id' of undefined ``` Cause: A policy silently filtered out all rows (not technically an RLS error, but caused by overly restrictive policies). Fix: Test with the correct user context; usesupabase.auth.getUser() to confirm identity before querying.Testing Policies with Proper Auth Context
This is critical: policies only work when you're authenticated. Test like this:
```javascript // ❌ Wrong: unauthenticated request const { data, error } = await supabase .from('posts') .select('*'); // Returns empty or permission denied
// ✅ Correct: sign in first const { data: { user } } = await supabase.auth.signInWithPassword({ email: 'user@example.com', password: 'password123' });
const { data, error } = await supabase .from('posts') .select('*'); // Now returns only posts where published=true OR user_id matches ```
Advanced: Using JWT Claims
For role-based access, add custom claims to your JWT:
```sql -- Policy using custom "role" claim CREATE POLICY "admin_can_moderate" ON posts FOR UPDATE USING (auth.jwt() ->> 'role' = 'admin') WITH CHECK (auth.jwt() ->> 'role' = 'admin'); ```
Set custom claims via Supabase Auth with a trigger (see [auth customization guide](/?guide=supabase-auth-claims)).
Common RLS Mistakes
1. Forgetting to enable RLS — policies exist but don't enforce
2. No SELECT policy for INSERT — you can insert but never read it back
3. Overly permissive USING clauses — e.g., USING (true) defeats security
4. Testing without auth — policies silently fail; always sign in during tests
5. Mixing USING and WITH CHECK incorrectly — use both for UPDATE
Performance Considerations
RLS policies can impact query speed. Best practices:
user_id)USING clausesEXPLAIN ANALYZE to inspect query plansauth.uid() repeatedly```sql -- Good: indexed column CREATE INDEX idx_posts_user_id ON posts(user_id);
-- Bad: function call in policy CREATE POLICY "slow_policy" ON posts FOR SELECT USING (user_id = (SELECT id FROM users WHERE email = current_user)); ```
Debugging: View All Policies
When things break, inspect your policies:
```sql SELECT tablename, policyname, qual, with_check FROM pg_policies WHERE tablename = 'posts' ORDER BY tablename, policyname; ```
Run this in your Supabase SQL editor to see every policy on every table.
Resources
---
What am I missing?
RLS is nuanced—policies for multi-tenant apps, team-based access, or hierarchical permissions get complex fast. Drop corrections and advanced patterns in the comments. Did I oversimplify a permission model? Miss a version-specific feature? Let's crowdsource the definitive guide.