Supabase Row Level Security: Beginner's Guide 2026
Master RLS policies in Supabase with real code examples. Learn authentication patterns, avoid common pitfalls, and secure your PostgreSQL data.
TL;DR
Row Level Security (RLS) in Supabase enforces data access at the PostgreSQL level using policies tied to authenticated users. Enable RLS on tables, write policies matching your auth scheme, and test thoroughly—misconfigured policies leak data or block legitimate queries.
---
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. Instead of securing data at your application layer, RLS secures it *inside the database*.
Why this matters: Even if someone bypasses your API, they can't access rows they shouldn't see. Your database becomes the source of truth for access control.
Supabase (v1.67+, verify in [official docs](https://supabase.com/docs/guides/auth/row-level-security)) makes RLS approachable with:
---
Enable RLS on Your Table
First, turn on RLS. Without it, policies don't matter:
```sql ALTER TABLE posts ENABLE ROW LEVEL SECURITY; ```
Critical: Once RLS is enabled on a table, *all queries require a matching policy*. If you enable RLS without policies, you'll see:
``` Error: new row violates row-level security policy for table "posts" ```
Or on SELECT:
``` Error: no rows produced by implicit set operation without ALL ```
Always create your first ALLOW SELECT policy before enabling RLS in production.
---
Core Pattern: User-Owned Data
Most indie hackers start here—each user sees only their own data:
```sql -- Table structure CREATE TABLE posts ( id BIGSERIAL PRIMARY KEY, user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, title TEXT NOT NULL, content TEXT, created_at TIMESTAMPTZ DEFAULT NOW() );
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- Policy 1: Users can SELECT their own posts CREATE POLICY "Users can view their own posts" ON posts FOR SELECT USING (auth.uid() = user_id);
-- Policy 2: Users can INSERT their own posts CREATE POLICY "Users can create posts" ON posts FOR INSERT WITH CHECK (auth.uid() = user_id);
-- Policy 3: Users can UPDATE their own posts CREATE POLICY "Users can update their own posts" ON posts FOR UPDATE USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);
-- Policy 4: Users can DELETE their own posts CREATE POLICY "Users can delete their own posts" ON posts FOR DELETE USING (auth.uid() = user_id); ```
Key function: auth.uid() returns the currently authenticated user's UUID from the JWT token. If no token, it returns NULL (denies access).
---
Testing Policies Locally
This is where most developers stumble. Use the Authorization header with a valid JWT:
```bash
Get your anon key from Supabase dashboard
curl -X GET http://localhost:54321/rest/v1/posts \ -H "apikey: $SUPABASE_ANON_KEY" \ -H "Authorization: Bearer $USER_JWT_TOKEN" ```Or test directly in SQL editor with:
```sql -- Simulate auth context for current session SET request.jwt.claims = jsonb_build_object( 'sub', '12345678-1234-1234-1234-123456789012' );
-- Now queries use this user_id SELECT * FROM posts; -- Only shows posts where user_id matches ```
Common mistake: Testing without setting auth context. You'll always see empty results because auth.uid() returns NULL.
---
Advanced: Shared/Public Data
Not everything is private. Share data with a status flag:
```sql CREATE TABLE articles ( id BIGSERIAL PRIMARY KEY, user_id UUID NOT NULL REFERENCES auth.users(id), title TEXT NOT NULL, is_published BOOLEAN DEFAULT FALSE, created_at TIMESTAMPTZ DEFAULT NOW() );
ALTER TABLE articles ENABLE ROW LEVEL SECURITY;
-- Anyone can read published articles CREATE POLICY "Public articles are readable" ON articles FOR SELECT USING (is_published = true);
-- Authors can read their own (published or draft) CREATE POLICY "Authors can read own articles" ON articles FOR SELECT USING (auth.uid() = user_id);
-- Only author can publish CREATE POLICY "Authors can update own articles" ON articles FOR UPDATE USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id); ```
---
Debugging Failed Queries
When queries silently fail (return 0 rows), check Postgres logs:
``` Error: permission denied for table "users" ```
This means RLS exists but no policy matches. Verify:
1. Is RLS enabled? SELECT tablename FROM pg_tables WHERE schemaname='public' AND tablename='posts';
2. Does the policy exist? SELECT * FROM pg_policies WHERE tablename='posts';
3. Is auth.uid() returning the right value? Add logging:
```sql CREATE POLICY "Debug policy" ON posts FOR SELECT USING ( (SELECT auth.uid()) = user_id ); ```
---
Connection to Authentication
RLS depends entirely on [JWT authentication setup](/?guide=supabase-auth-setup). Your anon client key must match your JWT secret. If using a custom auth system, you must inject the user_id into the JWT claims yourself—Supabase won't auto-generate it.
[Read our guide on Supabase auth strategies](/?guide=auth-patterns) for deeper integration patterns.
---
Production Checklist
EXPLAIN ANALYZE to ensure policies don't cause N+1 queries---
What am I missing?
This guide covers the foundation, but RLS gets complex with:
What patterns are *you* using in production? Share exact policy scenarios in comments—real-world examples help everyone level up.