Guides / new row violates row-level security policy

last checked oct 2026

Supabase "new row violates row-level security policy for table": what it means and how to fix it

Your insert or upsert fails with new row violates row-level security policy for table "todos" (error code 42501). Postgres refused to write the row because Row Level Security is on for that table and no policy allows this row for the role making the request. The fix is a correct policy or a correct request. Turning RLS off is not a fix.

The short answer

When a policy's WITH CHECK expression returns false or null for a new row, Postgres raises an error and aborts the whole command. If RLS is enabled and no policy applies at all, Postgres uses a default-deny policy. The usual causes:

Getting this error on a file upload instead? See the Supabase Storage version of this error, which covers storage.objects policies.

Why a null auth.uid() blocks the insert

Supabase's auth.uid() returns the ID of the user making the request, and it returns null when there's no signed-in user. A policy like with check ((select auth.uid()) = user_id) then compares null = user_id, which is never true, so the check fails and you get this error. Supabase recommends making the intent explicit with auth.uid() IS NOT NULL and naming the role with to authenticated.

How to debug it

1. List the policies on the table

Run this in the SQL Editor, with your table name:

select policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public' and tablename = 'todos';

You need a row whose cmd is INSERT or ALL and whose roles include the role your app uses (authenticated for signed-in users, anon for signed-out visitors). If roles is {public}, the policy was created without a TO clause and applies to every role. The with_check column shows the expression the new row must pass. For a FOR ALL policy with no WITH CHECK, Postgres uses the USING expression for new rows too.

2. Check who the request runs as

Supabase maps every API request to a Postgres role: anon when the user isn't signed in, authenticated when they are. If your insert runs before sign-in finishes, or from server code that never received the user's session, it runs as anon and auth.uid() is null. Supabase Auth's anonymous sign-ins are different: those users get the authenticated role.

3. Reproduce it as that user in SQL

Superusers and table owners normally skip RLS, so a test from an admin connection proves nothing. Switch to the app's role and identity inside a transaction, then roll back:

begin;
set local role authenticated;
set local request.jwt.claim.sub = '00000000-0000-0000-0000-000000000001'; -- a real user's id
insert into public.todos (title, user_id)
values ('rls test', '00000000-0000-0000-0000-000000000001');
rollback;

If this fails with the same error, the policy is the problem. If it works, look at what your app actually sends: the role, the session and the user_id value.

How to fix it

Add an INSERT policy for signed-in users

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

An INSERT policy can only have a WITH CHECK expression, not USING. This one lets a signed-in user add rows that belong to them, and refuses rows with anyone else's user_id.

Set user_id on the server side

If your frontend doesn't send user_id, give the column a default so Postgres fills it in from the session:

alter table public.todos
  alter column user_id set default auth.uid();

A column left out of an insert gets its default value. The policy still checks the final row, so a client that sends someone else's ID is still refused.

Upserts and .insert().select()

By default .insert() and .upsert() don't return rows. Chaining .select() asks Postgres to return them, and Postgres then requires the new row to pass a SELECT policy too. If it doesn't, you get an error rather than an empty result. An upsert is INSERT ... ON CONFLICT DO UPDATE. Postgres checks the INSERT policies' WITH CHECK for every proposed row, checks the proposed rows against SELECT policies, and, when a row already exists, checks the existing row against the UPDATE policy's USING and the updated row against its WITH CHECK. So an upsert needs all three:

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

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

Match the policy to the right role

If users are signed in, the policy needs to authenticated. Only write a to anon INSERT policy if signed-out visitors really should add rows, as with a public contact form, and keep its WITH CHECK as narrow as the form.

Don't "fix" it by turning protection off. alter table ... disable row level security makes the table readable and writable by any role with a grant on it, which on many projects includes anon. Putting the service_role key or a secret key in your frontend lets anyone who copies it from your site bypass RLS, and Supabase says never to use a secret key in the browser. A policy of with check (true) lets any signed-in user write rows for any other user. See service_role key exposed in client code and RLS policies that are always true.

Confirm it worked

Re-run the SQL test from step 3. It should succeed with the user's own ID. Change the user_id in the insert to a different user's ID and it should fail with the same error. Then try it with set local role anon;: it should fail too, with either this error or permission denied for table if anon has no grant on the table.

free check

Did a secret key end up in your frontend?

If you pasted a service_role or secret key into client code while chasing this error, LockedVibe's free Leak Checker can look at your live site for exposed keys, the way any visitor's browser would. It can't see your database policies.

Sources

Every Postgres and Supabase detail above was checked against these official pages. Last checked Oct 2026. If behavior changed, the docs below are the source of truth.