Supabase RLS Policies, One Operation at a Time

RedHub AI Editorialupdated October 4, 20267 min read

Two gym members stand at the same locker, each with a key that opens it, the shared locker lit red.
Jump to a section8

Supabase RLS policies are Postgres rules that each cover one command, or all of them, on one table: select, insert, update or delete. A policy filters existing rows with a USING clause, checks new or changed rows with a WITH CHECK clause, or both. Supabase's guide recommends a separate policy per operation, so reading a table correctly means reading four answers. They overlap in one way that matters: an update or delete has to get past the select policy too.

TL;DR: Select and delete use USING, which decides which existing rows the command can see or touch. Insert uses WITH CHECK, which decides whether the new row may be written. Update uses both, and if you leave out WITH CHECK, Postgres reuses the USING condition for the new row. Policies for the same operation are combined with OR, so one loose policy undoes a careful one. Row level security works on rows, not columns. The Supabase RLS Read-Off ($69) reads every table as four rows, one per operation.

Four operations, two clauses

OperationClauseWhat it checksWhere it goes wrong
SelectUSINGWhich existing rows the request can seeWritten as true on a table that holds private rows
InsertWITH CHECKWhether the new row may exist as writtenNo check that the new row's owner is the person writing it
UpdateUSING and WITH CHECKWhich row can be touched, and what it may becomeA WITH CHECK ( true ) that lets a user hand a row to someone else
DeleteUSINGWhich existing rows can be removedLeft as true on a table everyone can read

A USING clause filters rows that already exist. Rows it rejects are usually skipped without an error. A WITH CHECK clause tests the row as it will look after the write, and a row it rejects raises an error. Select and delete only ever need USING, because there is no new row to test. Insert only needs WITH CHECK, because there is no existing row to filter. Update does both jobs.

The four policies, written out

For a notes table where each row belongs to one user through a user_id column, Supabase's documented pattern looks like this:

alter table public.notes enable row level security;

create policy "Owners read their notes"
on public.notes for select
to authenticated
using ( (select auth.uid()) = user_id );

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

create policy "Owners edit their notes"
on public.notes for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );

create policy "Owners delete their notes"
on public.notes for delete
to authenticated
using ( (select auth.uid()) = user_id );

Three details in that SQL are deliberate. to authenticated means the policy never runs for signed-out requests, which Supabase recommends on every policy. auth.uid() returns the signed-in user's id, and null for a signed-out one, so the comparison can't match a stranger. Wrapping it as (select auth.uid()) lets Postgres evaluate it once per statement instead of once per row, which Supabase's guide gives as a performance rule, along with an index on user_id.

The update policy's WITH CHECK is what stops a user from editing their note and setting user_id to someone else's id. Postgres reuses the USING condition when WITH CHECK is left out, so the same policy without it would still block that. Writing it out keeps the intent visible to whoever reads the policy next.

Where the operations lean on each other

It is tempting to read the four policies as four unrelated answers. Postgres's documentation says otherwise. An update or delete that filters on a column, which is how a client names the row it means, needs read access, so the select policy applies as well.

The overlap matters in practice. A delete policy of true on an owner-only table reaches only the caller's own rows, which is less dangerous than it looks. The same policy on a table anyone can read reaches every row. And Supabase's guide says an update needs a matching select policy to work as expected, so an update that fails for no visible reason is often a missing select policy, not a broken update policy.

Insert doesn't lean on the select policy that way. There is no existing row to filter, so the WITH CHECK clause is the whole defense. When an insert policy is missing the owner comparison, any user can create rows that claim to belong to someone else.

Two policies on one operation

A table can carry more than one policy for the same operation. Postgres combines permissive policies, the default kind, with OR: a row passes if any one of them allows it. A careful owner-only select policy and a leftover using ( true ) from testing add up to everyone can read. Restrictive policies, declared as restrictive, combine with AND and must all pass, which is how Supabase's guide adds an extra requirement, such as a second sign-in factor, on top of a permissive policy.

So the question per operation is never "is there a policy." It is what all of that operation's permissive policies admit, taken together.

Row level, not column level

A correct-looking update policy still leaves one gap. Supabase's column-level security guide notes that a row policy like the one above gives the owner full access to update the row, every column included. If a profiles table keeps a plan or role column next to the avatar, the owner can set their own plan to paid through the same API call that changes their avatar.

There are two fixes, and Supabase prefers one. Column privileges can revoke update on the sensitive columns. Supabase calls that an advanced feature and recommends instead keeping roles in a dedicated table the user can't write to, checked from the policy. Which fits depends on your schema, and no per-operation reading will tell you, because the update policy is doing exactly what it says.

If an AI tool wrote your policies, read them with this in mind, the way our post on securing an app you didn't fully write suggests reading any generated code. The same check in application code is covered in the record ownership check, and the pattern of reading one table at a time is in RLS Is Enabled and Your Data Is Still Readable.

Four answers per table, not one badge

The Supabase RLS Read-Off gives every table four rows, select, insert, update and delete, and asks of each whether a policy exists and whether it compares the row to the signed-in user. It rolls up to TABLES LOCKED / GAPS IN THE POLICIES / PROJECT OPEN. One .xlsx, offline, connected to nothing.

Get the Supabase RLS Read-Off — $69

Pairs well with

The Data Model Sanity Bench ($69) reads whether each table has the owner column and constraints these policies depend on, and rolls up to MODEL HOLDS / YOU ARE THE CONSTRAINT / NOTHING TO BUILD ON. Logged-In Is Not Allowed ($59) reads the same ownership question across your app's routes, where a login answers who you are but not whether this record is yours. The Accept-or-Revert Ladder ($59) is for the moment an AI tool hands you a policy or schema change and the only button is Accept. It routes each change SAFE TO ACCEPT / NOTHING AT RISK / CHECKPOINT FIRST / DO NOT ACCEPT YET, and on a schema change a restorable copy is required.

More in this guide

Frequently asked questions

How many RLS policies can one table have?

As many as you write. Each policy covers one command, or all of them with FOR ALL, and several permissive policies on the same command are combined with OR. Supabase recommends one policy per operation so it stays clear which rule covers what.

What's the difference between USING and WITH CHECK?

USING filters existing rows, deciding which rows a select, update or delete can see or touch. WITH CHECK tests the row as it will look after an insert or update. Update uses both, and if WITH CHECK is left out, Postgres applies the USING condition to the new row as well.

Does a delete policy work on its own?

Not when the delete filters on a column, which is how clients name the row to remove. Postgres then applies the select policy too, so a delete reaches only rows the person can also read. That makes an open delete policy far more dangerous on a table everyone can read.

Why does my update policy fail when it looks correct?

Supabase's documentation says an update needs a matching select policy to work as expected. Check the select policy first, then the table's grants, since a missing grant fails with a 42501 permission error before any policy runs.

Can an RLS policy stop a user from changing one column?

No. Row level security decides which rows a user can update, not which columns. To protect a column such as a plan or role, revoke update on that column, or keep roles in a separate table the user can't write to, which is what Supabase recommends.

How it decides
Diagram of the Supabase RLS Read-Off: eight tables, a service-role override gate, and a sample reading GAPS IN THE POLICIES.

The gate this post refers to, drawn from the tool’s own logic. See the tool.