Skip to main content
RapidDev - Software Development Agency
supabase-tutorial

How to Write Row Level Security Policies in Supabase

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.

What you'll learn

  • How to enable RLS and understand why it blocks all access by default
  • How to write SELECT, INSERT, UPDATE, and DELETE policies with auth.uid()
  • How to use the anon and authenticated roles to control public vs private access
  • How to optimize RLS policies with indexes and per-statement caching
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner13 min read20-25 minSupabase (all plans), PostgreSQL 14+, all Supabase client librariesMarch 2026RapidDev Engineering Team
TL;DR

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

1

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.

typescript
1-- Enable RLS on a table
2alter table public.todos enable row level security;
3
4-- Verify RLS is enabled
5select tablename, rowsecurity
6from pg_tables
7where schemaname = 'public';
8
9-- Find tables WITHOUT RLS (security audit)
10select tablename from pg_tables
11where schemaname = 'public' and not rowsecurity;

Expected result: RLS is enabled on the table. All API access is blocked until policies are created.

2

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.

typescript
1-- Policy for public access (no login required)
2create policy "Anyone can read published posts"
3 on public.posts for select
4 to anon, authenticated
5 using (published = true);
6
7-- Policy for authenticated users only
8create policy "Logged-in users can read all posts"
9 on public.posts for select
10 to authenticated
11 using (true);
12
13-- Never grant write access to anon unless absolutely necessary
14-- BAD: create policy "Anyone can insert" on public.posts for insert to anon ...
15-- GOOD: Only authenticated users can write
16create policy "Auth users can insert"
17 on public.posts for insert
18 to authenticated
19 with check (true);

Expected result: You understand that anon = unauthenticated requests and authenticated = logged-in users, and you can target policies to specific roles.

3

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.

typescript
1-- Users can only see their own todos
2create policy "Users can view own todos"
3 on public.todos for select
4 to authenticated
5 using ((select auth.uid()) = user_id);
6
7-- Public read access for a blog
8create policy "Anyone can read published articles"
9 on public.articles for select
10 to anon, authenticated
11 using (status = 'published');
12
13-- Team-based access: users see data from their team
14create policy "Team members can view team data"
15 on public.projects for select
16 to authenticated
17 using (
18 team_id in (
19 select team_id from public.team_members
20 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.

4

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.

typescript
1-- Users can only create their own todos
2create policy "Users can insert own todos"
3 on public.todos for insert
4 to authenticated
5 with check ((select auth.uid()) = user_id);
6
7-- Users can only create posts where they are the author
8create policy "Authors can create posts"
9 on public.posts for insert
10 to authenticated
11 with check (
12 (select auth.uid()) = author_id
13 and status in ('draft', 'published')
14 );
15
16-- Allow inserts with additional field validation
17create policy "Users can add team members to own teams"
18 on public.team_members for insert
19 to authenticated
20 with check (
21 team_id in (
22 select id from public.teams
23 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.

5

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.

typescript
1-- Users can update their own todos
2create policy "Users can update own todos"
3 on public.todos for update
4 to authenticated
5 using ((select auth.uid()) = user_id)
6 with check ((select auth.uid()) = user_id);
7
8-- Authors can update their own posts but cannot change authorship
9create policy "Authors can update own posts"
10 on public.posts for update
11 to authenticated
12 using ((select auth.uid()) = author_id)
13 with check ((select auth.uid()) = author_id);
14
15-- Admins can update any post (using JWT custom claims)
16create policy "Admins can update any post"
17 on public.posts for update
18 to authenticated
19 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.

6

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.

typescript
1-- Users can delete their own todos
2create policy "Users can delete own todos"
3 on public.todos for delete
4 to authenticated
5 using ((select auth.uid()) = user_id);
6
7-- Only admins can delete posts
8create policy "Admins can delete any post"
9 on public.posts for delete
10 to authenticated
11 using (
12 (select auth.jwt() -> 'app_metadata' ->> 'role') = 'admin'
13 );
14
15-- Team owners can delete team projects
16create policy "Team owners can delete projects"
17 on public.projects for delete
18 to authenticated
19 using (
20 team_id in (
21 select id from public.teams
22 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.

7

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.

typescript
1-- Complete example: Secure a todos table
2alter table public.todos enable row level security;
3
4create policy "select_own" on public.todos
5 for select to authenticated
6 using ((select auth.uid()) = user_id);
7
8create policy "insert_own" on public.todos
9 for insert to authenticated
10 with check ((select auth.uid()) = user_id);
11
12create policy "update_own" on public.todos
13 for update to authenticated
14 using ((select auth.uid()) = user_id)
15 with check ((select auth.uid()) = user_id);
16
17create policy "delete_own" on public.todos
18 for delete to authenticated
19 using ((select auth.uid()) = user_id);
20
21-- Performance: index the column used in policies
22create index idx_todos_user_id on public.todos using btree (user_id);
23
24-- Or use a combined policy for all operations
25-- (simpler but less granular)
26create policy "full_access_own" on public.todos
27 for all to authenticated
28 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.

8

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.

typescript
1-- Test as anon: should return empty for authenticated-only tables
2-- curl with just the anon key
3
4-- Test as authenticated user: should return only their rows
5-- Use the Supabase Dashboard > API > Authentication section
6
7-- Quick test in SQL Editor (bypasses RLS - for admin verification only)
8select * from public.todos;
9
10-- Test RLS as a specific role
11set 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

rls-policies.sql
1-- ============================================
2-- Comprehensive RLS Policies for Supabase
3-- Run in Supabase SQL Editor
4-- ============================================
5
6-- 1. User profiles: public read, owner write
7alter table public.profiles enable row level security;
8
9create policy "Profiles are publicly readable"
10 on public.profiles for select
11 to anon, authenticated
12 using (true);
13
14create policy "Users can insert own profile"
15 on public.profiles for insert
16 to authenticated
17 with check ((select auth.uid()) = id);
18
19create policy "Users can update own profile"
20 on public.profiles for update
21 to authenticated
22 using ((select auth.uid()) = id)
23 with check ((select auth.uid()) = id);
24
25-- 2. Posts: public read if published, author full control
26alter table public.posts enable row level security;
27
28create policy "Published posts are public"
29 on public.posts for select
30 to anon, authenticated
31 using (published = true);
32
33create policy "Authors see own drafts"
34 on public.posts for select
35 to authenticated
36 using ((select auth.uid()) = author_id);
37
38create policy "Authors can create posts"
39 on public.posts for insert
40 to authenticated
41 with check ((select auth.uid()) = author_id);
42
43create policy "Authors can update own posts"
44 on public.posts for update
45 to authenticated
46 using ((select auth.uid()) = author_id)
47 with check ((select auth.uid()) = author_id);
48
49create policy "Authors can delete own posts"
50 on public.posts for delete
51 to authenticated
52 using ((select auth.uid()) = author_id);
53
54-- 3. Comments: authenticated read, owner write
55alter table public.comments enable row level security;
56
57create policy "Anyone can read comments"
58 on public.comments for select
59 to anon, authenticated
60 using (true);
61
62create policy "Auth users can insert comments"
63 on public.comments for insert
64 to authenticated
65 with check ((select auth.uid()) = user_id);
66
67create policy "Users can delete own comments"
68 on public.comments for delete
69 to authenticated
70 using ((select auth.uid()) = user_id);
71
72-- 4. Performance indexes for RLS columns
73create 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);
77
78-- 5. Admin override using JWT custom claims
79create policy "Admins can do anything on posts"
80 on public.posts for all
81 to authenticated
82 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.

ChatGPT Prompt

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.

Supabase Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.