Supabase

Fix Supabase "new row violates row-level security policy" (Error 42501)

9 min read · Updated October 16, 2026 · Checked against official docs

You click “Save” in your AI-built app and nothing happens. In the browser console (or in the network tab) you find this:

{
  "code": "42501",
  "message": "new row violates row-level security policy for table \"projects\""
}

Good news first: this error means your database is protecting itself. Row Level Security (RLS) is on, and no policy says this particular row is allowed in. The bad fix, the one AI tools often suggest, is to disable RLS. Don’t. With RLS off, your public key (which is in your JavaScript for everyone to see) can read and write that table directly.

This guide walks through the causes in the order you’re most likely to hit them, with SQL you can paste into the Supabase SQL Editor. If RLS itself is new to you, read Supabase Row Level Security for Beginners first. It’s a ten-minute read and makes everything below easier.

What the error actually means

Postgres runs two checks before a request touches a table:

  1. Grants decide whether a role (anon for signed-out visitors, authenticated for signed-in users) may run an operation like insert at all.
  2. Policies decide which rows that operation is allowed for.

For an insert, the policy that matters is the with check part. Postgres builds the new row, runs your with check expression against it, and if no insert policy returns true, you get “new row violates row-level security policy”.

Supabase’s docs add an important detail: a missing grant also raises 42501, before any policy runs. So the same error code can come from two different places. (Supabase: Row Level Security)

Step 0: find out who is asking

Before you touch any SQL, check whether the request is coming from a signed-in user. Add this right above the failing insert, run it once, and look at the console:

const { data: { user } } = await supabase.auth.getUser();
console.log('Signed in as:', user?.id ?? 'NOBODY (anon)');

If it prints NOBODY, jump to Cause 3. If it prints an id, keep that id handy. You’ll compare it to the row you’re inserting.

Cause 1: there is no INSERT policy at all

This is the most common one in AI-generated apps. The tool turns on RLS (correct) and writes a select policy so the list page works, but forgets that saving needs its own policy. RLS denies by default, per operation, so reads work and writes fail.

Check: open Authentication → Policies in the dashboard (or Database → Policies, depending on your dashboard version), find the table, and look for a policy whose command is INSERT. Or run:

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

Fix: add an insert policy that only lets users create rows that belong to them:

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

Wrapping auth.uid() in (select ...) is the form Supabase uses in its own examples. It lets Postgres evaluate it once per statement instead of once per row.

Cause 2: the policy exists, but the row doesn’t pass it

You have with check ((select auth.uid()) = user_id), the user is signed in, and it still fails. Look at what the app actually sends. Very often the generated code never sets user_id:

// ❌ user_id is missing, so it's NULL, and NULL = auth.uid() is not true
await supabase.from('projects').insert({ name });

Two fixes. Use either one (or both).

Fix A: set it in the code.

const { data: { user } } = await supabase.auth.getUser();
await supabase.from('projects').insert({ name, user_id: user!.id });

Fix B: let the database fill it in. This is the safer default because the browser can’t forget it:

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

With Fix B, an insert without user_id gets the signed-in user’s id automatically, and the policy passes. An attacker who sends someone else’s user_id still fails the with check, which is exactly what you want.

Also check the column type. auth.uid() returns a uuid. If user_id is a text column, Postgres rejects the comparison with an “operator does not exist: uuid = text” error. Make the column uuid and, ideally, a foreign key to auth.users(id).

Cause 3: there is no signed-in user

If Step 0 printed NOBODY, then the request runs as anon, auth.uid() is NULL, and every “own rows” policy fails. Typical reasons:

  • The user signed up but never confirmed their email, so no session exists yet.
  • The session expired, or the user was signed in on the preview URL and is now on your real domain (sessions don’t carry across domains).
  • Server code (an API route or Edge Function) created a fresh Supabase client with only the publishable key and didn’t pass along the user’s login token.

For the last case, forward the user’s token when you create the client on the server:

import { createClient } from '@supabase/supabase-js';

const supabase = createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
  global: { headers: { Authorization: req.headers.get('Authorization')! } },
});

If anonymous inserts are genuinely what you want (a public contact form, say), write a policy to anon with a tight with check, and think about spam before you ship it.

Cause 4: insert works, but reading the new row back fails

This one confuses everyone. Your code looks like this:

const { data, error } = await supabase
  .from('projects')
  .insert({ name })
  .select();   // "give me the row back"

.select() turns the request into INSERT ... RETURNING, and returning a row means reading it, which needs a select policy that allows that row. If you only have an insert policy, you get an RLS error even though the insert itself was allowed. The same applies to upsert, because INSERT ... ON CONFLICT checks rows against the table’s select policies too, and the failure is reported with the same “new row violates” message. (PostgreSQL: CREATE POLICY)

Fix: add the matching select policy:

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

Or, if you don’t need the row back, remove .select() from the insert.

Cause 5: the grant is missing

Supabase’s docs note that grants vary between projects: older projects give anon and authenticated every privilege on new public tables automatically, newer setups may not. A missing grant raises 42501 before your policies even run, so no policy change will help. (Supabase: Securing your API)

Check:

select grantee, privilege_type
from information_schema.role_table_grants
where table_schema = 'public' and table_name = 'projects'
  and grantee in ('anon', 'authenticated');

Fix: grant only what the role needs:

grant select, insert, update, delete on table public.projects to authenticated;

While you’re there, check the opposite problem: if anon holds insert on a table signed-out visitors should never write to, revoke it. Policies alone shouldn’t be your only lock.

What about UPDATE?

The same message also appears on updates when the new version of the row fails the update policy’s with check. Example: a user edits a project and the form accidentally sends user_id: null. For updates you usually want both clauses:

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

using picks which rows the user may touch. with check stops them from handing the row to someone else. Note that an update blocked by using doesn’t error at all. It just updates zero rows, which is why “save does nothing, no error” is a related symptom.

The full, safe pattern for an “each user owns their rows” table

If you want to reset a table to a known-good state, this is the whole thing in one block. Change projects to your table name:

alter table public.projects enable row level security;
alter table public.projects alter column user_id set default auth.uid();

grant select, insert, update, delete on table public.projects to authenticated;
revoke all on table public.projects from anon;

create policy "own rows: select" on public.projects
  for select to authenticated using ( (select auth.uid()) = user_id );

create policy "own rows: insert" on public.projects
  for insert to authenticated with check ( (select auth.uid()) = user_id );

create policy "own rows: update" on public.projects
  for update to authenticated
  using ( (select auth.uid()) = user_id )
  with check ( (select auth.uid()) = user_id );

create policy "own rows: delete" on public.projects
  for delete to authenticated using ( (select auth.uid()) = user_id );

If the table already has policies with these names, drop or rename them first. Run it on a test project before production if you can.

A prompt for your AI tool

Paste this into Lovable, Bolt or Cursor instead of “fix the error”:

My insert into the "projects" table fails with
"new row violates row-level security policy" (42501).
Do NOT disable RLS and do NOT use the service role / secret key in the browser.
1) Show me every RLS policy and grant on this table.
2) Check whether the insert sets user_id, and whether .select() or upsert is used.
3) Propose the smallest SQL change (policy, default, or grant) that lets a
   signed-in user insert only rows where user_id = auth.uid().
Show me the SQL before running it.

Prove it’s fixed

  1. Sign in as user A, create a row. It should save.
  2. In the browser console, try inserting a row with another user’s id as user_id. It should fail with the same 42501 error. That failure is your proof the policy works.
  3. Sign out and try the insert again. It should fail.
  4. Run Supabase’s Security Advisor and read every warning for the table.

For a broader sweep before launch, use the pre-launch security checklist. And if you ever pasted your secret key into browser code to make this error go away, treat it as leaked and rotate it: see what to do if you leaked an API key.


Official sources used: Supabase: Row Level Security · Supabase: Securing your API · PostgreSQL: CREATE POLICY. Checked October 2026.

Want the whole launch path in one place?

The Ship It Kit bundles copy-paste prompts, step-by-step deploy guides and a pre-launch checklist for Lovable, Bolt, Cursor and Replit apps. One-time $19. Launch price: $19 $17 until November 14, 2026.

Get the Ship It Kit, $19$17 See what's inside

Spotted something out of date? Platforms change their screens often. The official docs linked above are always the source of truth.