Supabase Row Level Security 2026: Beginner Guide
Master Supabase RLS with production patterns, real errors, and step-by-step setup to secure your database at row level.
TL;DR
Row Level Security (RLS) in Supabase enforces database-level access control using PostgreSQL policies. Enable RLS on tables, create policies matching your auth logic, and test thoroughly—misconfigured policies cause silent data leaks or 42501 permission denied errors.What is Row Level Security?
Row Level Security is a PostgreSQL feature that restricts which rows users can access based on database policies. Unlike application-level filtering, RLS policies execute *inside the database*, making them impossible to bypass through direct API calls or compromised client code.
Supabase integrates PostgreSQL's native RLS with its Auth system, allowing you to write policies that reference auth.uid() and user claims.
Enable RLS on Your Table
RLS is disabled by default. You must explicitly enable it:
```sql ALTER TABLE public.posts ENABLE ROW LEVEL SECURITY; ```
Critical: Enabling RLS without policies blocks all access except the table owner. You'll immediately see:
``` Error: new row violates row-level security policy for table "posts" ```
This is expected—now create policies.
Core RLS Policies: READ, INSERT, UPDATE, DELETE
Each policy targets a specific operation. Here's a real-world example for a posts table:
```sql -- Allow users to SELECT their own posts + published posts CREATE POLICY "Users can view own or published posts" ON public.posts FOR SELECT USING ( auth.uid() = user_id OR is_published = true );
-- Allow users to INSERT only their own posts CREATE POLICY "Users can create posts" ON public.posts FOR INSERT WITH CHECK (auth.uid() = user_id);
-- Allow users to UPDATE only their own posts CREATE POLICY "Users can update own posts" ON public.posts FOR UPDATE USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);
-- Allow users to DELETE only their own posts CREATE POLICY "Users can delete own posts" ON public.posts FOR DELETE USING (auth.uid() = user_id); ```
Key distinction: USING applies to existing rows; WITH CHECK validates new/modified rows.
Real Console Errors You'll See
Error 1: Permission Denied (42501) ``` Error: permission denied for schema public Code: 42501 ``` Cause: No SELECT policy exists, or the policy condition returned false.
Error 2: Row-Level Security Violation
```
Error: new row violates row-level security policy for table "posts"
Code: 20000
```
Cause: INSERT/UPDATE WITH CHECK clause failed.
Error 3: Silent Data Loss ``` // Query succeeds but returns empty array—no error thrown const { data } = await supabase .from('posts') .select('*'); console.log(data); // [] ``` Cause: RLS filtering matched zero rows. Always test with known data.
Production Pattern: Role-Based Access
Use custom claims in JWT tokens for fine-grained control:
```sql -- Table: documents CREATE TABLE public.documents ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), title text NOT NULL, owner_id uuid NOT NULL REFERENCES auth.users(id), team_id uuid NOT NULL, created_at timestamp DEFAULT now() );
ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY;
-- Policy: Users see docs from their team CREATE POLICY "team_members_can_view" ON public.documents FOR SELECT USING ( team_id = (auth.jwt() ->> 'user_metadata')::jsonb->>'team_id'::uuid );
-- Policy: Only owners can update CREATE POLICY "owners_can_update" ON public.documents FOR UPDATE USING (owner_id = auth.uid()) WITH CHECK (owner_id = auth.uid()); ```
Testing RLS Policies
Test policies in the SQL Editor with set role to simulate different users:
```sql -- Test as authenticated user SET ROLE authenticated; SET request.jwt.claims = '{"sub": "550e8400-e29b-41d4-a716-446655440000"}';
SELECT * FROM public.posts; -- Should return only rows matching policy
RESET ROLE; RESET request.jwt.claims; ```
In application code, switch users by using their actual auth tokens:
```javascript const { data: userPosts, error } = await supabase .from('posts') .select('*') .eq('user_id', userAId);
// Should succeed and return data if (error) console.error('RLS blocked:', error.code); ```
Common Mistakes
Mistake 1: Forgetting auth.uid() is null for anon
```sql
-- WRONG: Anon users see nothing (auth.uid() is NULL)
CREATE POLICY "public_view"
ON public.posts
FOR SELECT
USING (auth.uid() = user_id);
-- RIGHT: Allow anon to see published posts CREATE POLICY "public_view" ON public.posts FOR SELECT USING ( is_published = true OR (auth.uid() IS NOT NULL AND auth.uid() = user_id) ); ```
Mistake 2: Cascading deletes without RLS If you delete a user, orphaned rows may violate policies. Use database triggers:
```sql CREATE TRIGGER cascade_delete_user_posts AFTER DELETE ON auth.users FOR EACH ROW EXECUTE FUNCTION delete_user_posts(); ```
Mistake 3: Over-permissive policies ```sql -- DANGEROUS: Grants access to all rows CREATE POLICY "allow_all" ON public.posts FOR SELECT USING (true); ```
Verify in Official Docs
Supabase RLS documentation is version-agnostic, but verify:
Related Guides
What am I missing?
Have you hit edge cases with RLS policies? Spotted inaccuracies in policy syntax? Drop corrections and real-world scenarios in comments—let's build a better resource for the indie hacker community.