Supabase Row Level Security: Beginner's Guide 2026
Master RLS policies in Supabase with production patterns, real errors, and exact SQL. Secure your PostgreSQL data at the database layer.
TL;DR
Row Level Security (RLS) in Supabase enforces data access at the database layer using PostgreSQL policies. Enable RLS on tables, write policies targeting auth.uid(), and test thoroughly—misconfigured policies cause silent failures or "permission denied" errors. This guide covers setup, real errors, and production patterns.
---
What is Row Level Security?
Row Level Security is a PostgreSQL feature that restricts which rows users can access based on policies you define. Supabase wraps this with authentication context, letting you write policies like: "only show posts where user_id matches the authenticated user."
Without RLS, your application's auth logic is the only barrier. A single bug exposes all data. With RLS, the *database itself* enforces access—even if your API is compromised.
---
Setup: Enable RLS on a Table
Step 1: Create a table
```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, created_at TIMESTAMP WITH TIME ZONE DEFAULT now() ); ```
Step 2: Enable RLS
```sql ALTER TABLE posts ENABLE ROW LEVEL SECURITY; ```
Critical: Without this, policies won't apply. Verify in your table editor in Supabase dashboard—you'll see a lock icon next to the table name once enabled.
Step 3: Create a policy
```sql CREATE POLICY "Users can view their own posts" ON posts FOR SELECT USING (auth.uid() = user_id); ```
Breakdown:
FOR SELECT – applies to read operationsUSING (condition) – rows matching this condition are visibleauth.uid() – Supabase function returning the authenticated user's UUIDStep 4: Allow inserts
```sql CREATE POLICY "Users can create posts" ON posts FOR INSERT WITH CHECK (auth.uid() = user_id); ```
WITH CHECK ensures the inserted row satisfies the condition. Users can't set user_id to someone else's UUID.
Step 5: Allow updates and deletes
```sql CREATE POLICY "Users can update their own posts" ON posts FOR UPDATE USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can delete their own posts" ON posts FOR DELETE USING (auth.uid() = user_id); ```
---
Real Errors You'll Hit
Error 1: Silent Empty Results
Symptom: Query returns [] but you expected data.
``` -- In browser console after querying posts const { data } = await supabase .from('posts') .select('*') console.log(data); // [] ```
Cause: RLS policy denies access. The client doesn't error—it silently filters.
Fix: Check that your policy condition is correct. Use Supabase SQL editor to test:
```sql SELECT auth.uid(); -- Should return a UUID, not NULL SELECT * FROM posts WHERE auth.uid() = user_id; ```
Error 2: Permission Denied on Insert
``` Error: new row violates row-level security policy "Users can create posts" on table "posts" ```
Cause: Your WITH CHECK clause is rejecting the insert. Most common: forgetting to set user_id in your INSERT.
Production pattern:
```javascript const { data, error } = await supabase .from('posts') .insert([ { title: 'My post', content: 'Content here', user_id: (await supabase.auth.getUser()).data.user.id } ]) .select();
if (error) console.error('RLS blocked insert:', error.message); ```
Error 3: NULL Auth Context
``` Error: permission denied for schema public ```
Cause: auth.uid() returns NULL (user not authenticated), and your policy doesn't handle anonymous access.
Fix: If you want anonymous reads, create a separate policy:
```sql CREATE POLICY "Anonymous can view published posts" ON posts FOR SELECT USING (published = true); ```
---
Production-Ready Patterns
Pattern 1: Multi-tenant with organization roles
```sql -- Assume auth.jwt() contains org_id claim CREATE POLICY "Users see org data they belong to" ON documents FOR SELECT USING ( org_id = (auth.jwt() ->> 'org_id')::uuid AND EXISTS ( SELECT 1 FROM org_members WHERE org_id = documents.org_id AND user_id = auth.uid() ) ); ```
Pattern 2: Public + private posts
```sql CREATE POLICY "Anyone can view published posts" ON posts FOR SELECT USING (published = true);
CREATE POLICY "Users see their own draft posts" ON posts FOR SELECT USING (published = false AND auth.uid() = user_id); ```
Pattern 3: Admin bypass
```sql CREATE POLICY "Admins bypass RLS" ON posts FOR ALL USING ( auth.jwt() ->> 'role' = 'admin' ); ```
Verify in official docs: [Supabase RLS guide](https://supabase.com/docs/guides/auth/row-level-security) – check current syntax for auth.jwt() usage.
---
Testing RLS Policies
Use Supabase's SQL editor with the "Execute as" dropdown to test as different users:
```sql -- Execute as user 123e4567-e89b-12d3-a456-426614174000 SELECT * FROM posts; ```
Or in JavaScript:
```javascript // Test as authenticated user const user = await supabase.auth.getUser(); console.log('Logged in as:', user.data.user.id);
const { data, error } = await supabase .from('posts') .select('*');
if (error) console.log('RLS error:', error); ```
---
Common Gotchas
1. RLS doesn't apply to service role key – Service role bypasses all policies. Use it only in trusted server contexts.
2. auth.uid() is NULL for anonymous users – Always handle anonymous access explicitly.
3. Policies are additive for SELECT – If two SELECT policies exist, a row is visible if it matches *either*.
4. UPDATE/DELETE policies use BOTH USING and WITH CHECK – USING filters visible rows; WITH CHECK validates the change.
---
Debugging Checklist
SELECT * FROM pg_policies WHERE tablename = 'your_table';auth.uid() returns non-NULL value in SQL editor.SELECT * FROM table WHERE {policy_condition};anon key for client-side, never service role.For Supabase library versions, verify in official docs: [JavaScript client library](https://supabase.com/docs/reference/javascript/release-notes).
---
Related Reading
---
What am I missing?
RLS is complex—policies interact with joins, aggregations, and real-time subscriptions in subtle ways. What edge cases have burned you? Missing a pattern for shared documents, team permissions, or testing strategies? Drop it in the comments.