Supabase Row Level Security: Beginner's Guide 2026
Master Supabase RLS policies to secure your database at the row level. Real code examples, common errors, and production patterns.
TL;DR
Supabase Row Level Security (RLS) enforces database-level access control using PostgreSQL policies. Enable RLS on tables, create policies with USING and WITH CHECK clauses, and authenticate users via JWT. Test policies immediately—they're tricky to debug in production.
---
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. Unlike application-level authorization, RLS is enforced *at the database level*, making it impossible to bypass with clever API queries.
Supabase (verify exact pricing in [official docs](https://supabase.com/docs/guides/auth/row-level-security)) bundles PostgreSQL's RLS with its Auth service for seamless integration.
Why RLS Matters
1. Database-level enforcement: No way to bypass with direct SQL 2. Multi-tenant safety: Each user sees only their data by default 3. Reduced app logic: Authorization logic lives in SQL, not your backend 4. Performance: PostgreSQL optimizes policies before executing queries
---
Setup: Enable RLS on a Table
First, create a table and enable RLS:
```sql CREATE TABLE todos ( id BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, title TEXT NOT NULL, completed BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT NOW() );
-- Enable RLS ALTER TABLE todos ENABLE ROW LEVEL SECURITY; ```
Critical: After ALTER TABLE todos ENABLE ROW LEVEL SECURITY, users have *zero* access until you create policies. You'll see:
``` ERROR: new row violates row-level security policy for table "todos" ```
This is expected. Now create policies.
---
Core Concepts: USING vs WITH CHECK
Each policy has two optional clauses:
USING: Controls which rows are *visible* (SELECT, UPDATE, DELETE)WITH CHECK: Controls which rows can be *modified* (INSERT, UPDATE)```sql -- Users can SELECT their own todos CREATE POLICY "Users can view own todos" ON todos FOR SELECT USING (auth.uid() = user_id);
-- Users can INSERT todos for themselves CREATE POLICY "Users can insert own todos" ON todos FOR INSERT WITH CHECK (auth.uid() = user_id);
-- Users can UPDATE their own todos CREATE POLICY "Users can update own todos" ON todos FOR UPDATE USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);
-- Users can DELETE their own todos CREATE POLICY "Users can delete own todos" ON todos FOR DELETE USING (auth.uid() = user_id); ```
The auth.uid() function returns the current user's UUID from the JWT token. [Verify current auth functions in docs](https://supabase.com/docs/guides/auth/row-level-security#builtin-functions).
---
Real Error Messages You'll Encounter
Error 1: Policies Don't Exist Yet
``` ERROR: new row violates row-level security policy for table "todos" DETAIL: Policy ("Users can view own todos") denied access ```
Fix: Make sure all four policies (SELECT, INSERT, UPDATE, DELETE) are created. Check with:
```sql SELECT schemaname, tablename, policyname, qual, with_check FROM pg_policies WHERE tablename = 'todos'; ```
Error 2: JWT Token Missing
``` ERROR: current transaction is aborted, commands ignored until end of transaction block ```
Or in browser console:
``` POST https://your-project.supabase.co/rest/v1/todos 401 Unauthorized {"message":"Unauthorized"} ```
Fix: Ensure you're calling supabase.auth.signIn() or supabase.auth.setSession() before making table queries. The JWT must be in the Authorization: Bearer header.
Error 3: Ambiguous user_id in Policy
``` ERROR: syntax error at or near "user_id" LINE 3: USING (user_id = auth.uid()); ```
Fix: Qualify the column name—use todos.user_id when ambiguous:
```sql CREATE POLICY "Users can view own todos" ON todos FOR SELECT USING (todos.user_id = auth.uid()); ```
---
Production-Ready Pattern: Role-Based Access
For more complex scenarios, store user roles and check them:
```sql CREATE TABLE user_roles ( user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, role TEXT NOT NULL CHECK (role IN ('admin', 'editor', 'viewer')), created_at TIMESTAMP DEFAULT NOW() );
ALTER TABLE user_roles ENABLE ROW LEVEL SECURITY;
-- Admins and editors can view all todos -- Regular users can only view their own CREATE POLICY "Conditional access based on role" ON todos FOR SELECT USING ( auth.uid() = user_id OR EXISTS ( SELECT 1 FROM user_roles WHERE user_roles.user_id = auth.uid() AND user_roles.role IN ('admin', 'editor') ) ); ```
---
Testing RLS Policies Locally
Use Supabase's built-in SQL editor to test before deployment:
```sql -- Simulate a user (replace with real UUID) SET request.jwt.claim.sub = 'YOUR_USER_UUID';
-- Now queries will respect RLS SELECT * FROM todos; ```
Or test via SDK:
```javascript const { data, error } = await supabase .from('todos') .select('*');
if (error) console.log('RLS blocked query:', error.message); ```
---
Common Pitfalls
1. Forgetting ON DELETE CASCADE: If you delete a user, their user_id becomes orphaned. Use ON DELETE CASCADE on foreign keys.
2. Testing without auth context: Policies require a valid JWT. Unauthenticated requests will be blocked.
3. Over-permissive USING clauses: USING (true) makes the table public. Always validate auth.uid().
4. Not indexing user_id: Create an index for performance:
```sql
CREATE INDEX idx_todos_user_id ON todos(user_id);
```
---
Debugging Checklist
ALTER TABLE todos ENABLE ROW LEVEL SECURITY;SELECT * FROM pg_policies WHERE tablename='todos';sub claim (user UUID)user_id column exists and is indexedSET request.jwt.claim.sub---
Related Guides
---
References
---
What am I missing?
Have you hit issues with RLS policies? Found a better pattern for multi-tenant apps? Please comment below—your real-world experience helps other indie hackers avoid these traps. Also let me know if any version numbers need updating for 2026 compatibility.