# How to Write Row Level Security Policies in Supabase

- Tool: Supabase
- Difficulty: Beginner
- Time required: 20-25 min
- Compatibility: Supabase (all plans), PostgreSQL 14+, all Supabase client libraries
- Last updated: March 2026

## 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.

## Before you start

- 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.

```
-- Enable RLS on a table
alter table public.todos enable row level security;

-- Verify RLS is enabled
select tablename, rowsecurity
from pg_tables
where schemaname = 'public';

-- Find tables WITHOUT RLS (security audit)
select tablename from pg_tables
where 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.

```
-- Policy for public access (no login required)
create policy "Anyone can read published posts"
  on public.posts for select
  to anon, authenticated
  using (published = true);

-- Policy for authenticated users only
create policy "Logged-in users can read all posts"
  on public.posts for select
  to authenticated
  using (true);

-- Never grant write access to anon unless absolutely necessary
-- BAD: create policy "Anyone can insert" on public.posts for insert to anon ...
-- GOOD: Only authenticated users can write
create policy "Auth users can insert"
  on public.posts for insert
  to authenticated
  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.

```
-- Users can only see their own todos
create policy "Users can view own todos"
  on public.todos for select
  to authenticated
  using ((select auth.uid()) = user_id);

-- Public read access for a blog
create policy "Anyone can read published articles"
  on public.articles for select
  to anon, authenticated
  using (status = 'published');

-- Team-based access: users see data from their team
create policy "Team members can view team data"
  on public.projects for select
  to authenticated
  using (
    team_id in (
      select team_id from public.team_members
      where user_id = (select auth.uid())
    )
  );
```

**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.

```
-- Users can only create their own todos
create policy "Users can insert own todos"
  on public.todos for insert
  to authenticated
  with check ((select auth.uid()) = user_id);

-- Users can only create posts where they are the author
create policy "Authors can create posts"
  on public.posts for insert
  to authenticated
  with check (
    (select auth.uid()) = author_id
    and status in ('draft', 'published')
  );

-- Allow inserts with additional field validation
create policy "Users can add team members to own teams"
  on public.team_members for insert
  to authenticated
  with check (
    team_id in (
      select id from public.teams
      where owner_id = (select auth.uid())
    )
  );
```

**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.

```
-- Users can update their own todos
create policy "Users can update own todos"
  on public.todos for update
  to authenticated
  using ((select auth.uid()) = user_id)
  with check ((select auth.uid()) = user_id);

-- Authors can update their own posts but cannot change authorship
create policy "Authors can update own posts"
  on public.posts for update
  to authenticated
  using ((select auth.uid()) = author_id)
  with check ((select auth.uid()) = author_id);

-- Admins can update any post (using JWT custom claims)
create policy "Admins can update any post"
  on public.posts for update
  to authenticated
  using (
    (select auth.jwt() -> 'app_metadata' ->> 'role') = 'admin'
  );
```

**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.

```
-- Users can delete their own todos
create policy "Users can delete own todos"
  on public.todos for delete
  to authenticated
  using ((select auth.uid()) = user_id);

-- Only admins can delete posts
create policy "Admins can delete any post"
  on public.posts for delete
  to authenticated
  using (
    (select auth.jwt() -> 'app_metadata' ->> 'role') = 'admin'
  );

-- Team owners can delete team projects
create policy "Team owners can delete projects"
  on public.projects for delete
  to authenticated
  using (
    team_id in (
      select id from public.teams
      where owner_id = (select auth.uid())
    )
  );
```

**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.

```
-- Complete example: Secure a todos table
alter table public.todos enable row level security;

create policy "select_own" on public.todos
  for select to authenticated
  using ((select auth.uid()) = user_id);

create policy "insert_own" on public.todos
  for insert to authenticated
  with check ((select auth.uid()) = user_id);

create policy "update_own" on public.todos
  for update to authenticated
  using ((select auth.uid()) = user_id)
  with check ((select auth.uid()) = user_id);

create policy "delete_own" on public.todos
  for delete to authenticated
  using ((select auth.uid()) = user_id);

-- Performance: index the column used in policies
create index idx_todos_user_id on public.todos using btree (user_id);

-- Or use a combined policy for all operations
-- (simpler but less granular)
create policy "full_access_own" on public.todos
  for all to authenticated
  using ((select auth.uid()) = user_id)
  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.

```
-- Test as anon: should return empty for authenticated-only tables
-- curl with just the anon key

-- Test as authenticated user: should return only their rows
-- Use the Supabase Dashboard > API > Authentication section

-- Quick test in SQL Editor (bypasses RLS - for admin verification only)
select * from public.todos;

-- Test RLS as a specific role
set role authenticated;
set request.jwt.claims = '{"sub": "user-uuid-here"}';
select * from public.todos;
reset role;
```

**Expected result:** Anonymous requests return empty arrays for authenticated-only tables. Authenticated users see only their own data. Unauthorized writes silently fail.

## Complete code example

File: `rls-policies.sql`

```sql
-- ============================================
-- Comprehensive RLS Policies for Supabase
-- Run in Supabase SQL Editor
-- ============================================

-- 1. User profiles: public read, owner write
alter table public.profiles enable row level security;

create policy "Profiles are publicly readable"
  on public.profiles for select
  to anon, authenticated
  using (true);

create policy "Users can insert own profile"
  on public.profiles for insert
  to authenticated
  with check ((select auth.uid()) = id);

create policy "Users can update own profile"
  on public.profiles for update
  to authenticated
  using ((select auth.uid()) = id)
  with check ((select auth.uid()) = id);

-- 2. Posts: public read if published, author full control
alter table public.posts enable row level security;

create policy "Published posts are public"
  on public.posts for select
  to anon, authenticated
  using (published = true);

create policy "Authors see own drafts"
  on public.posts for select
  to authenticated
  using ((select auth.uid()) = author_id);

create policy "Authors can create posts"
  on public.posts for insert
  to authenticated
  with check ((select auth.uid()) = author_id);

create policy "Authors can update own posts"
  on public.posts for update
  to authenticated
  using ((select auth.uid()) = author_id)
  with check ((select auth.uid()) = author_id);

create policy "Authors can delete own posts"
  on public.posts for delete
  to authenticated
  using ((select auth.uid()) = author_id);

-- 3. Comments: authenticated read, owner write
alter table public.comments enable row level security;

create policy "Anyone can read comments"
  on public.comments for select
  to anon, authenticated
  using (true);

create policy "Auth users can insert comments"
  on public.comments for insert
  to authenticated
  with check ((select auth.uid()) = user_id);

create policy "Users can delete own comments"
  on public.comments for delete
  to authenticated
  using ((select auth.uid()) = user_id);

-- 4. Performance indexes for RLS columns
create index idx_profiles_id on public.profiles (id);
create index idx_posts_author_id on public.posts (author_id);
create index idx_posts_published on public.posts (published);
create index idx_comments_user_id on public.comments (user_id);

-- 5. Admin override using JWT custom claims
create policy "Admins can do anything on posts"
  on public.posts for all
  to authenticated
  using (
    (select auth.jwt() -> 'app_metadata' ->> 'role') = 'admin'
  );
```

## Common mistakes

- **Enabling RLS without creating any policies, causing the table to return empty results for all queries** — undefined Fix: 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.
- **Writing auth.uid() without the select wrapper, causing per-row function calls instead of per-statement caching** — undefined Fix: 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.
- **Forgetting that UPDATE requires a corresponding SELECT policy to read the row before modifying it** — undefined Fix: 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.
- **Using the service role key in client-side code, which bypasses all RLS policies** — undefined Fix: 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.
- **Omitting the TO clause in a policy, accidentally granting access to the anon role** — undefined Fix: 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

## 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.

---

Source: https://www.rapidevelopers.com/supabase-tutorial/how-to-write-row-level-security-policies-in-supabase
© RapidDev — https://www.rapidevelopers.com/supabase-tutorial/how-to-write-row-level-security-policies-in-supabase
