Supabase Row Level Security Guide 2026
Master RLS policies for PostgreSQL databases. Real errors, production patterns, and exact syntax for indie hackers building secure apps.
TL;DR
Row Level Security (RLS) in Supabase lets PostgreSQL enforce data access rules at the database layer. Enable it on tables, write policies matching your auth model, and test thoroughly before production. Real developers catch permission errors early.What is Row Level Security?
Row Level Security (RLS) is a PostgreSQL feature that Supabase surfaces through its dashboard and API. When enabled on a table, every query—regardless of source—must pass through RLS policies you define. Without RLS, your application layer is the only gatekeeper. One bug and data leaks.
Think of it as a second lock: 1. Application logic says "user can see this" 2. Database policy says "user can actually see this"
If either says no, the user sees nothing.
Enable RLS (The First Step)
In the Supabase dashboard, navigate to your table and toggle "Enable RLS." This is non-reversible for production—once enabled, ALL queries require matching policies or they fail.
You can also enable via SQL:
```sql ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY; ```
Before enabling RLS on existing tables with data, write your policies first. A table with RLS enabled but zero policies is completely locked down.
Write Your First Policy
Here's the canonical pattern for user-owned data:
```sql CREATE POLICY "Users can view their own documents" ON public.documents FOR SELECT USING (auth.uid() = user_id); ```
Breakdown:
public.documentsauth.uid() returns the authenticated user's UUIDThe USING clause runs for every row. If true, the row is included in results. If false, it's filtered out silently.
Real Error Message #1: "No rows returned"
You write a query that should return data, but get an empty result set. No error—just nothing. This usually means:
1. Your RLS policy doesn't match the query context
2. You're querying as a different user than the policy allows
3. The auth.uid() is NULL (common in service role queries)
Always test with select auth.uid() in your policy context to confirm the authenticated user is who you think it is.
CRUD Policies: The Production Pattern
Most tables need four policies—one per operation:
```sql -- SELECT (read) CREATE POLICY "select_own_documents" ON public.documents FOR SELECT USING (auth.uid() = user_id);
-- INSERT (create) CREATE POLICY "insert_own_documents" ON public.documents FOR INSERT WITH CHECK (auth.uid() = user_id);
-- UPDATE (modify) CREATE POLICY "update_own_documents" ON public.documents FOR UPDATE USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);
-- DELETE (remove) CREATE POLICY "delete_own_documents" ON public.documents FOR DELETE USING (auth.uid() = user_id); ```
Key difference: INSERT and UPDATE use WITH CHECK, which validates the *new* data. SELECT and DELETE use USING, which validates existing rows.
Real Error Message #2: "new row violates row level security policy"
You're trying to insert or update, but the WITH CHECK clause fails:
``` Postgres Error: new row violates row level security policy "insert_own_documents" for table "documents" ```
This means your WITH CHECK condition returned false. Common causes:
user_id to someone else's UUIDuser_id field entirely in the insert payloadShared Data: Team Policies
Not all data is user-owned. Teams, workspaces, and shared resources need different logic:
```sql CREATE POLICY "team_members_can_read" ON public.projects FOR SELECT USING ( EXISTS ( SELECT 1 FROM public.team_members tm WHERE tm.team_id = projects.team_id AND tm.user_id = auth.uid() AND tm.deleted_at IS NULL ) ); ```
This policy checks: "Can the current user read this project? Only if they're an active team member."
The EXISTS subquery runs for every row. It's slightly slower than direct comparisons but accurate for complex relationships.
Real Error Message #3: "Permission denied for schema public"
You've enabled RLS but haven't created any policies yet, OR you're using service role without explicit grants:
``` Postgres Error: permission denied for schema public ```
Service role bypasses RLS by design (it's meant for admin operations). If you see this, you likely: 1. Disabled RLS by accident 2. Deleted all policies 3. Used service role credentials in client code (security risk—use anon key)
Test RLS Before Production
Create test users and verify policies:
```sql -- As admin, create test user data INSERT INTO public.documents (id, user_id, title, content) VALUES ( gen_random_uuid(), '550e8400-e29b-41d4-a716-446655440000', 'Secret Doc', 'Only user 550e8400... should see this' );
-- Then authenticate as that user and query -- If you see the row, RLS works -- If you see nothing, your policy is wrong ```
In production, use [Supabase's policy simulator](https://supabase.com/docs/guides/auth/row-level-security) to verify complex policies before deployment.
Common Gotchas
Anonymous users: auth.uid() returns NULL for unauthenticated requests. Policies checking auth.uid() = user_id silently reject them. This is usually desired.
Performance: RLS policies add query overhead. Index columns used in USING/WITH CHECK clauses (like user_id). See [query optimization](/?guide=postgres-performance).
Cascade deletes: RLS doesn't prevent foreign key cascades, but it prevents the *user* from triggering them if they can't access the parent row.
Service role: Queries with service role credentials bypass RLS entirely. Never expose service role keys in client code. Use anon key + RLS + [realtime subscriptions](/?guide=supabase-realtime) instead.
Verify Official Docs
For version-specific details, [verify in official Supabase RLS docs](https://supabase.com/docs/guides/auth/row-level-security). Policy syntax may evolve; this guide reflects patterns current as of early 2026.
What am I missing?
Did this guide miss your RLS headache? Comment below:
Share your production patterns and we'll update this guide.