Row Level Security (RLS) is Supabase's primary authorization mechanism. Enable it on every table with ALTER TABLE ... ENABLE ROW LEVEL SECURITY, then write policies for SELECT, INSERT, UPDATE, and DELETE using auth.uid() to scope data to the authenticated user. Without policies, RLS blocks all access. Policies use USING for existing rows and WITH CHECK for new or modified rows. Always wrap auth.uid() in a select for performance.
Writing Row Level Security Policies in Supabase
Row Level Security is the cornerstone of Supabase's security model. It enforces authorization at the PostgreSQL level, meaning every API request — whether from the JS client, direct HTTP, or any other source — is subject to the same rules. This tutorial covers enabling RLS, writing policies for every CRUD operation, understanding the anon and authenticated roles, and optimizing policy performance. By the end, you will be able to secure any Supabase table with granular, per-row access control.
Prerequisites
- A Supabase project with at least one table in the public schema
- Access to the SQL Editor in the Supabase Dashboard
- Basic understanding of SQL WHERE clauses
- Understanding of authentication (users have a unique ID/UUID)
Step-by-step guide
Enable RLS on your table
Enable RLS on your table
Row Level Security is disabled by default on new tables. When you enable it, PostgreSQL immediately blocks all access to the table through the API — queries return empty arrays, and writes silently fail. This is secure by default: no data leaks until you explicitly create policies. Enable RLS on every table in the public schema, because any table without RLS is fully accessible to anyone with your anon key.
1-- Enable RLS on a table2alter table public.todos enable row level security;34-- Verify RLS is enabled5select tablename, rowsecurity6from pg_tables7where schemaname = 'public';89-- Find tables WITHOUT RLS (security audit)10select tablename from pg_tables11where schemaname = 'public' and not rowsecurity;Expected result: RLS is enabled on the table. All API access is blocked until policies are created.
Understand the anon and authenticated roles
Understand the anon and authenticated roles
Every API request to Supabase maps to one of two PostgreSQL roles. Requests with just the anon key (no user logged in) use the anon role. Requests with a valid JWT (user logged in) use the authenticated role. Your policies specify which roles can perform which operations. The TO clause in a policy controls which roles the policy applies to. Use anon for public data and authenticated for user-specific data.
1-- Policy for public access (no login required)2create policy "Anyone can read published posts"3 on public.posts for select4 to anon, authenticated5 using (published = true);67-- Policy for authenticated users only8create policy "Logged-in users can read all posts"9 on public.posts for select10 to authenticated11 using (true);1213-- Never grant write access to anon unless absolutely necessary14-- BAD: create policy "Anyone can insert" on public.posts for insert to anon ...15-- GOOD: Only authenticated users can write16create policy "Auth users can insert"17 on public.posts for insert18 to authenticated19 with check (true);Expected result: You understand that anon = unauthenticated requests and authenticated = logged-in users, and you can target policies to specific roles.
Write a SELECT policy to control who can read data
Write a SELECT policy to control who can read data
SELECT policies use the USING clause to define which existing rows are visible. The expression in USING is evaluated for every row, and only rows where it returns true are returned to the client. The most common pattern is (select auth.uid()) = user_id, which restricts each user to seeing only their own rows. For public data, use USING (true) with the appropriate role.
1-- Users can only see their own todos2create policy "Users can view own todos"3 on public.todos for select4 to authenticated5 using ((select auth.uid()) = user_id);67-- Public read access for a blog8create policy "Anyone can read published articles"9 on public.articles for select10 to anon, authenticated11 using (status = 'published');1213-- Team-based access: users see data from their team14create policy "Team members can view team data"15 on public.projects for select16 to authenticated17 using (18 team_id in (19 select team_id from public.team_members20 where user_id = (select auth.uid())21 )22 );Expected result: Users can only see rows that match the USING condition. Other rows are invisible — not forbidden, just absent from results.
Write an INSERT policy to control who can create data
Write an INSERT policy to control who can create data
INSERT policies use the WITH CHECK clause to validate the new row being inserted. The expression is evaluated against the row data being written. The most common pattern ensures users can only insert rows where they are the owner. If WITH CHECK returns false, the insert silently fails (returns empty data, not an error). This prevents users from creating records that belong to other users.
1-- Users can only create their own todos2create policy "Users can insert own todos"3 on public.todos for insert4 to authenticated5 with check ((select auth.uid()) = user_id);67-- Users can only create posts where they are the author8create policy "Authors can create posts"9 on public.posts for insert10 to authenticated11 with check (12 (select auth.uid()) = author_id13 and status in ('draft', 'published')14 );1516-- Allow inserts with additional field validation17create policy "Users can add team members to own teams"18 on public.team_members for insert19 to authenticated20 with check (21 team_id in (22 select id from public.teams23 where owner_id = (select auth.uid())24 )25 );Expected result: Users can only insert rows that pass the WITH CHECK condition. Attempts to insert rows with another user's ID silently fail.
Write an UPDATE policy to control who can modify data
Write an UPDATE policy to control who can modify data
UPDATE policies use both USING and WITH CHECK. The USING clause determines which existing rows the user can target for update (like a WHERE filter). The WITH CHECK clause validates the new values being written. Both must be satisfied for the update to succeed. A critical requirement: the user must also have a SELECT policy on the same rows, because PostgreSQL reads the row before updating it.
1-- Users can update their own todos2create policy "Users can update own todos"3 on public.todos for update4 to authenticated5 using ((select auth.uid()) = user_id)6 with check ((select auth.uid()) = user_id);78-- Authors can update their own posts but cannot change authorship9create policy "Authors can update own posts"10 on public.posts for update11 to authenticated12 using ((select auth.uid()) = author_id)13 with check ((select auth.uid()) = author_id);1415-- Admins can update any post (using JWT custom claims)16create policy "Admins can update any post"17 on public.posts for update18 to authenticated19 using (20 (select auth.jwt() -> 'app_metadata' ->> 'role') = 'admin'21 );Expected result: Users can only update rows they own (matched by USING) and the new values must also pass the WITH CHECK condition.
Write a DELETE policy to control who can remove data
Write a DELETE policy to control who can remove data
DELETE policies use only the USING clause to determine which rows the user can delete. Like UPDATE, the user must also have a SELECT policy to read the rows being deleted. The most common pattern restricts deletion to the row owner. Be careful with delete policies — accidental broad delete access can lead to data loss.
1-- Users can delete their own todos2create policy "Users can delete own todos"3 on public.todos for delete4 to authenticated5 using ((select auth.uid()) = user_id);67-- Only admins can delete posts8create policy "Admins can delete any post"9 on public.posts for delete10 to authenticated11 using (12 (select auth.jwt() -> 'app_metadata' ->> 'role') = 'admin'13 );1415-- Team owners can delete team projects16create policy "Team owners can delete projects"17 on public.projects for delete18 to authenticated19 using (20 team_id in (21 select id from public.teams22 where owner_id = (select auth.uid())23 )24 );Expected result: Users can only delete rows that match the USING condition. Attempts to delete other users' rows silently fail.
Combine all policies and add performance indexes
Combine all policies and add performance indexes
A properly secured table has policies for every operation that users need. For a typical user-owned table, you need four policies: SELECT, INSERT, UPDATE, and DELETE. Add btree indexes on the columns used in policy expressions (like user_id) to prevent full table scans. Without indexes, every policy check requires scanning every row in the table.
1-- Complete example: Secure a todos table2alter table public.todos enable row level security;34create policy "select_own" on public.todos5 for select to authenticated6 using ((select auth.uid()) = user_id);78create policy "insert_own" on public.todos9 for insert to authenticated10 with check ((select auth.uid()) = user_id);1112create policy "update_own" on public.todos13 for update to authenticated14 using ((select auth.uid()) = user_id)15 with check ((select auth.uid()) = user_id);1617create policy "delete_own" on public.todos18 for delete to authenticated19 using ((select auth.uid()) = user_id);2021-- Performance: index the column used in policies22create index idx_todos_user_id on public.todos using btree (user_id);2324-- Or use a combined policy for all operations25-- (simpler but less granular)26create policy "full_access_own" on public.todos27 for all to authenticated28 using ((select auth.uid()) = user_id)29 with check ((select auth.uid()) = user_id);Expected result: The table is fully secured with per-operation policies and indexed for performance. Each user can only access their own rows.
Test your policies by querying with different roles
Test your policies by querying with different roles
Verify your policies work correctly by testing with the anon key (unauthenticated) and as an authenticated user. Use curl or the Supabase Dashboard's API section to make direct requests. Check that unauthorized operations return empty results (not errors) and authorized operations return the expected data. Also test edge cases like trying to insert rows with another user's ID.
1-- Test as anon: should return empty for authenticated-only tables2-- curl with just the anon key34-- Test as authenticated user: should return only their rows5-- Use the Supabase Dashboard > API > Authentication section67-- Quick test in SQL Editor (bypasses RLS - for admin verification only)8select * from public.todos;910-- Test RLS as a specific role11set role authenticated;12set request.jwt.claims = '{"sub": "user-uuid-here"}';13select * from public.todos;14reset role;Expected result: Anonymous requests return empty arrays for authenticated-only tables. Authenticated users see only their own data. Unauthorized writes silently fail.
Complete working example
1-- ============================================2-- Comprehensive RLS Policies for Supabase3-- Run in Supabase SQL Editor4-- ============================================56-- 1. User profiles: public read, owner write7alter table public.profiles enable row level security;89create policy "Profiles are publicly readable"10 on public.profiles for select11 to anon, authenticated12 using (true);1314create policy "Users can insert own profile"15 on public.profiles for insert16 to authenticated17 with check ((select auth.uid()) = id);1819create policy "Users can update own profile"20 on public.profiles for update21 to authenticated22 using ((select auth.uid()) = id)23 with check ((select auth.uid()) = id);2425-- 2. Posts: public read if published, author full control26alter table public.posts enable row level security;2728create policy "Published posts are public"29 on public.posts for select30 to anon, authenticated31 using (published = true);3233create policy "Authors see own drafts"34 on public.posts for select35 to authenticated36 using ((select auth.uid()) = author_id);3738create policy "Authors can create posts"39 on public.posts for insert40 to authenticated41 with check ((select auth.uid()) = author_id);4243create policy "Authors can update own posts"44 on public.posts for update45 to authenticated46 using ((select auth.uid()) = author_id)47 with check ((select auth.uid()) = author_id);4849create policy "Authors can delete own posts"50 on public.posts for delete51 to authenticated52 using ((select auth.uid()) = author_id);5354-- 3. Comments: authenticated read, owner write55alter table public.comments enable row level security;5657create policy "Anyone can read comments"58 on public.comments for select59 to anon, authenticated60 using (true);6162create policy "Auth users can insert comments"63 on public.comments for insert64 to authenticated65 with check ((select auth.uid()) = user_id);6667create policy "Users can delete own comments"68 on public.comments for delete69 to authenticated70 using ((select auth.uid()) = user_id);7172-- 4. Performance indexes for RLS columns73create index idx_profiles_id on public.profiles (id);74create index idx_posts_author_id on public.posts (author_id);75create index idx_posts_published on public.posts (published);76create index idx_comments_user_id on public.comments (user_id);7778-- 5. Admin override using JWT custom claims79create policy "Admins can do anything on posts"80 on public.posts for all81 to authenticated82 using (83 (select auth.jwt() -> 'app_metadata' ->> 'role') = 'admin'84 );Common mistakes when writing Row Level Security Policies in Supabase
Why it's a problem: Enabling RLS without creating any policies, causing the table to return empty results for all queries
How to avoid: Always create at least a SELECT policy immediately after enabling RLS. The 'zero access by default' behavior is intentional but catches many developers off guard.
Why it's a problem: Writing auth.uid() without the select wrapper, causing per-row function calls instead of per-statement caching
How to avoid: Always write (select auth.uid()) instead of auth.uid() in policy expressions. The select wrapper caches the result for the entire statement, dramatically improving performance on large tables.
Why it's a problem: Forgetting that UPDATE requires a corresponding SELECT policy to read the row before modifying it
How to avoid: Always create a SELECT policy that covers the same rows as your UPDATE policy. Without it, updates silently fail because PostgreSQL cannot read the row to modify it.
Why it's a problem: Using the service role key in client-side code, which bypasses all RLS policies
How to avoid: Only use SUPABASE_ANON_KEY in browsers and mobile apps. The service role key bypasses RLS entirely and should never be exposed to the client.
Why it's a problem: Omitting the TO clause in a policy, accidentally granting access to the anon role
How to avoid: Always specify TO anon, TO authenticated, or TO anon, authenticated explicitly. Omitting TO applies the policy to all roles including anon.
Best practices
- Enable RLS on every table in the public schema — treat it as mandatory, not optional
- Use (select auth.uid()) instead of auth.uid() for per-statement caching and better performance
- Create separate policies for SELECT, INSERT, UPDATE, and DELETE for maximum granularity
- Always specify the role with TO anon, TO authenticated, or both — never omit the TO clause
- Add btree indexes on columns used in policy expressions (user_id, team_id, etc.)
- Avoid joins in policy expressions — use IN or ANY with subqueries instead for better performance
- Test policies by querying with the anon key and as authenticated users to verify access is correct
- Use auth.jwt() -> 'app_metadata' for role-based policies when you need admin or moderator access
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I have a Supabase database with profiles, posts, and comments tables. Write comprehensive RLS policies so that: profiles are publicly readable but only editable by the owner, posts are publicly readable when published but only the author can CRUD them, and comments are readable by everyone but only the author can create and delete them. Include performance indexes.
Enable RLS on my todos table and create four policies: SELECT (users see only their own rows), INSERT (users can only create rows with their user_id), UPDATE (users can only modify their own rows), DELETE (users can only delete their own rows). Use (select auth.uid()) for caching and add a btree index on user_id.
Frequently asked questions
What happens when RLS is enabled but no policies exist?
All access is silently denied. SELECT queries return empty arrays (not errors), and INSERT, UPDATE, DELETE operations silently fail. This is secure by default — no data leaks until you explicitly create policies.
What is the difference between USING and WITH CHECK?
USING filters which existing rows are visible or targetable (used by SELECT, UPDATE, DELETE). WITH CHECK validates the data being written (used by INSERT, UPDATE). For UPDATE, both clauses apply: USING filters which rows can be updated, and WITH CHECK validates the new values.
Does the service role key bypass RLS?
Yes. The service role key connects as the postgres superuser, which has BYPASSRLS privilege. Never use it in client-side code. It is intended for server-side operations like Edge Functions, cron jobs, and admin APIs.
Can I have multiple policies on the same table?
Yes. Multiple policies for the same operation are combined with OR logic — a row is accessible if ANY policy allows it. Multiple policies for different operations are independent. This lets you layer access rules.
How do I debug RLS policies that are not working?
First, check that the table has RLS enabled. Then use SET ROLE in the SQL Editor to test as the anon or authenticated role. Check that your auth.uid() expression matches the column type (UUID vs text). Use EXPLAIN ANALYZE to see if policies are being applied.
Why does my UPDATE fail even though I have an UPDATE policy?
UPDATE requires a corresponding SELECT policy because PostgreSQL reads the row before modifying it. Create a SELECT policy that covers the same rows as your UPDATE policy.
How do I create admin-level access that bypasses user-scoped policies?
Add a policy that checks for an admin role in the JWT custom claims: USING ((select auth.jwt() -> 'app_metadata' ->> 'role') = 'admin'). Set this claim server-side when creating admin users.
Can RapidDev help set up RLS policies for my Supabase project?
Yes, RapidDev specializes in Supabase security configurations. They can design and implement RLS policies tailored to your application's access patterns, including role-based access, team-based access, and multi-tenant architectures.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation