← All posts
·10 min read·GuardLayer

Fix: new row violates row-level security policy

SupabaseRLSPostgresAuthNext.js

TL;DR: The Postgres error new row violates row-level security policy for table "X" (SQLSTATE 42501) means RLS is enabled but the row you're inserting didn't pass a WITH CHECK policy — usually because no INSERT policy exists, or because user_id in the row doesn't equal auth.uid(). Fix it by adding an INSERT policy: create policy "..." on public.todos for insert to authenticated with check ((select auth.uid()) = user_id); — and make sure the row's user_id is actually set to the logged-in user.

This error is RLS doing its job. Postgres received a write, evaluated your row-level security policy against the new row, the predicate came back false, and the entire statement was aborted. Nothing is broken — a rule rejected the row. The whole task is to figure out which rule, and whether the row or the policy is wrong.

Why does my insert throw "new row violates row-level security policy"?

Because RLS is enabled on the table and the new row failed the WITH CHECK expression of your INSERT policy — or there is no INSERT policy at all, so Postgres default-denies the write.

An INSERT has no existing row to read, so Postgres validates it using only the policy's WITH CHECK predicate. Two things trip almost everyone up:

  1. RLS is on, but there's no passing INSERT policy. When you run alter table ... enable row level security with no INSERT policy, Postgres applies a default-deny: no permissive policy means no row can be written. A SELECT-only policy does not cover INSERT. Each command needs its own policy.
  2. The policy exists, but the row's values fail the check. The classic case is with check ((select auth.uid()) = user_id) where the client never set user_id, or set it to a different user. auth.uid() = NULL evaluates to NULL, and RLS only lets a row through when the predicate is TRUE — NULL and FALSE both fail — so you get 42501.

Here is the correct INSERT policy:

alter table public.todos enable row level security;

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

Note three deliberate choices. for insert uses WITH CHECK, never USINGUSING inspects existing rows and is meaningless for an insert, so Postgres rejects it outright on a for insert policy (ERROR: only WITH CHECK expression allowed for INSERT). Getting these two backwards is the single most common cause of 42501. to authenticated scopes the policy to logged-in users so the anon role never even reaches it. And wrapping the auth call as (select auth.uid()) lets the planner evaluate it once per statement as a cached initPlan instead of once per row — a real win on large tables, and the pattern Supabase recommends in its RLS performance guide.

Why does it still fail after I added an INSERT policy?

Two likely reasons: the row's user_id doesn't equal auth.uid(), or you chained .select() onto the insert and have no SELECT policy.

The user_id mismatch. Your WITH CHECK only passes if the row's user_id really equals the caller. Don't leave that to chance — and don't trust the client to send the right value. The cleanest fix is a column default so the client can omit the column entirely:

alter table public.todos
  alter column user_id set default auth.uid(),
  alter column user_id set not null;
// No user_id sent — the database fills it in
const { error } = await supabase.from('todos').insert({ task: 'Buy milk' })

One caveat: a DEFAULT only applies when the client omits the column. If a client sends its own user_id, the default is bypassed and the client value is used. So DEFAULT alone is not a spoofing guard — it works because the WITH CHECK policy still rejects a wrong value. For a hard server-side guarantee, use a BEFORE INSERT trigger that overwrites whatever the client sent:

create or replace function public.set_user_id()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
  new.user_id := auth.uid();   -- overwrite whatever the client sent
  return new;
end;
$$;

create trigger set_user_id_before_insert
before insert on public.todos
for each row execute function public.set_user_id();

The rule to internalize: never treat a client-supplied user_id as trustworthy. Derive ownership server-side and enforce it with WITH CHECK. Belt and suspenders.

The auth.uid() is NULL case. If auth.uid() returns NULL, NULL = user_id is never true and every insert fails. This happens when no valid JWT reaches the database — the anon key was used instead of the user's access token, the session expired, or the Authorization header wasn't attached. This deserves its own diagnosis; see why auth.uid() returns NULL in your RLS policies for the full checklist. If you're new to how INSERT, SELECT, UPDATE and DELETE policies fit together, the complete guide to Supabase Row Level Security walks through each command.

Why does .insert().select() fail when the plain insert works?

Because .select() makes PostgREST run the INSERT and then a SELECT to return the row, and that read is evaluated against your SELECT policy — which you may not have.

In supabase-js v2, .insert() returns nothing by default. You only trigger the read-back when you explicitly add .select(). When you do, an insert with a valid INSERT policy but no SELECT policy still throws new row violates row-level security policy — the failure is on the read-back, not the write. Postgres is explicit about this in the CREATE POLICY reference: a RETURNING row that fails the relation's SELECT policy raises an error rather than being silently dropped.

Two fixes. Add a matching SELECT policy:

create policy "Users can read their own todos"
on public.todos
for select
to authenticated
using ( (select auth.uid()) = user_id );
const { data, error } = await supabase
  .from('todos')
  .insert({ task: 'Buy milk' })
  .select()   // now succeeds — the SELECT policy lets the caller read the new row

Or, if you don't need the row back, just drop .select(). (The old v1 { returning: 'minimal' } flag is gone in v2 — "minimal" is the v2 default.) Upsert is especially prone to this: it needs INSERT, UPDATE, and SELECT policies to all pass, and the conflict-target key must be in the values.

Why does my UPDATE throw the same error?

Because UPDATE is checked twice, and you probably omitted WITH CHECK. USING decides which existing rows you may target; WITH CHECK validates what each row is allowed to become.

create policy "Users can update their own todos"
on public.todos
for update
to authenticated
using       ( (select auth.uid()) = user_id )   -- may only target rows they own
with check  ( (select auth.uid()) = user_id );  -- may not reassign to someone else

If you omit WITH CHECK, Postgres silently reuses the USING expression as the check. That's fine when both conditions are identical, but it breaks the moment they differ — and a USING-only policy can let a user flip user_id to another user's UUID and hand off or steal ownership. Always write both explicitly. (Note: a row that fails USING isn't rejected with 42501 — it's simply invisible, and the UPDATE affects zero rows. The 42501 comes specifically from WITH CHECK on the new row image.)

Is this actually "permission denied for table" instead?

Both errors are SQLSTATE 42501, but they come from different layers and the message tells them apart:

MessageFailed layerFix
new row violates row-level security policy for table "x"RLS policy (row-level)Add/repair the policy (above)
permission denied for table xGRANT (table-level privilege)grant insert on table public.todos to authenticated;

If the role lacks the table GRANT, RLS never even runs — privileges are checked before policies. Tables created through the Supabase dashboard usually get anon/authenticated grants automatically, but tables created via raw SQL migrations often don't — which is exactly when "permission denied for table" shows up.

Test the insert as a real user, not in the SQL editor

The Supabase SQL editor runs as the postgres role, which owns your tables and therefore isn't subject to their RLS policies — so your insert "works" there and still fails from the client. A classic false negative. Exercise the policy by impersonating the role inside a transaction:

begin;

set local role authenticated;
set local request.jwt.claims to
  '{ "sub": "00000000-0000-0000-0000-000000000000", "role": "authenticated" }';

-- auth.uid() now returns the "sub" above:
insert into public.todos (task, user_id)
values ('test', '00000000-0000-0000-0000-000000000000');

rollback;  -- undo the row AND reset role/claims

auth.uid() reads request.jwt.claims ->> 'sub', so setting that claim makes the policy behave exactly as it does for a logged-in client. Using set local inside begin … rollback resets the role, the claims, and the test row automatically.

Where GuardLayer fits — and where it honestly doesn't

Be clear-eyed about this: 42501 is RLS working correctly. It's a safe denial of a write. A static scanner can't tell at runtime that you forgot an INSERT policy, and GuardLayer does not claim to detect or "fix" the 42501 error itself. If it did, it would be lying to you.

What GuardLayer does catch is the dangerous inverse — policies that are too permissive and let writes through when they shouldn't. Its static rules flag USING (true) predicates (supabase/policy-using-true), policies whose predicate references none of auth.uid(), auth.jwt(), or auth.role() and so aren't user-scoped at all (supabase/policy-no-user-scope), tables with RLS never enabled (supabase/rls-missing-on-table), and RLS explicitly turned off (supabase/rls-explicitly-disabled). Those are the mistakes that don't throw an error — they fail open, silently. For the over-permissive traps specifically, see the USING (true) trap and what happens when a policy isn't user-scoped.

In other words: 42501 is the loud, safe failure you can fix in five minutes. GuardLayer exists to catch the quiet, unsafe one.

FAQ

What does SQLSTATE 42501 mean in Postgres? 42501 is INSUFFICIENT_PRIVILEGE. Two different messages share it: new row violates row-level security policy (an RLS WITH CHECK rejected the row) and permission denied for table (a missing table GRANT). The message text tells you which layer failed.

Do I use USING or WITH CHECK for an INSERT policy? WITH CHECK only. An INSERT has no existing row, so USING is meaningless and Postgres rejects it on a for insert policy. WITH CHECK validates the values being written.

Why does my insert work in the SQL editor but fail from my app? The SQL editor runs as the postgres role, which owns your tables and isn't subject to their RLS policies, so the policy is never exercised. Test with set local role authenticated and a forged request.jwt.claims inside a transaction to actually run the policy.

Why does .insert().select() fail when .insert() alone works? .select() makes PostgREST read the row back after inserting, and that read needs a SELECT policy. Add a for select policy, or drop .select() if you don't need the row returned.

Is 42501 a bug I need to disable RLS to fix? No — never disable RLS to make the error go away. 42501 is RLS correctly rejecting a row. Turning RLS off makes the table world-writable. Add the correct policy instead.

Should I trust the user_id the client sends? No. Set user_id server-side with a DEFAULT auth.uid() column or a BEFORE INSERT trigger, and keep the WITH CHECK policy as defense-in-depth so a forged id is rejected.

Catch this before it ships — free

GuardLayer scans every push for this and 23 other Next.js + Supabase issues, with the exact fix inline.

No signup, no card — your code is scanned in memory and never stored.

Keep reading