Skip to content
Nilay Kabariya

Entry No.047·Database··6 min read

Supabase error 42501: new row violates row-level security policy

Supabase's 42501 has two messages and seven causes. Each reproduced on a local stack, plus the October 30 change that breaks new tables.

by Nilay#supabase#postgresDATABASE

The error, verbatim

Jump to the fix ↓
{
  code: '42501',
  details: null,
  hint: null,
  message: 'new row violates row-level security policy for table "profiles"'
}

Tested on

supabase-js
2.117.2
Supabase CLI
2.120.0 (local stack)
Postgres
17.11
PostgREST
16.4
Node / OS
24.14.0 / Windows 11 Pro
Contents
  1. Which policy is missing01
  2. .insert().select() needs a SELECT policy too02
  3. You’re not signed in (yet)03
  4. user_id isn’t sent, so the check fails04
  5. Upsert: the “(USING expression)” version05
  6. Storage uploads06
  7. “permission denied for table”: the October 30 change07
  8. What didn’t work08
  9. How this was tested09

42501 is Postgres’s code for “insufficient privilege”. Supabase returns it in two very different situations, and the message tells you which one you’re in:

Message What blocked the request Status
new row violates row-level security policy for table "x" A row-level security (RLS) policy 401 signed out, 403 signed in
new row violates row-level security policy (USING expression) for table "x" The same, on an upsert that hit an existing row 403
new row violates row-level security policy (no table name) A Storage upload statusCode: "403"
permission denied for table x A missing table GRANT (not a policy) 401 / 403

The first three are about policies. The last one is about grants, and it’s the one that will suddenly show up on existing projects after October 30, 2026 (see the last section). Adding a policy does nothing for it, and I tested that.

I reproduced every row of that table on a local Supabase stack, with supabase-js calling the real API. Each section below starts with what triggered it and ends with the change that made the same call succeed.

Which policy is missing

Supabase’s RLS denies by default, per operation. A table with RLS on and only a SELECT policy can be read but not written. Inserting with the publishable (anon) key returned:

status: 401 Unauthorized
code: '42501'
message: 'new row violates row-level security policy for table "notes_selectonly"'

The same table with no policies at all behaved differently for reads: select() came back 200 with data: []. No error, just an empty list. So “reads work, writes fail” usually means you wrote a read policy and nothing else.

Add a policy for the operation that failed. For a table anyone can write to:

create policy "anyone can insert" on public.notes_selectonly
  for insert to anon, authenticated
  with check (true);

For rows that belong to a user (the usual profiles case), check the owner:

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

.insert().select() needs a SELECT policy too

This one catches people who did write an INSERT policy. With only an INSERT policy on the table:

  • insert({ body: 'x' }) returned 201 Created.
  • insert({ body: 'x' }).select() returned 401 and the same 42501 message.

Asking for the row back means Postgres has to be allowed to read the new row, and without a SELECT policy it isn’t. The whole insert is rolled back: I counted the rows afterwards, and the failed call left nothing behind.

Add a SELECT policy that covers the new row:

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

You’re not signed in (yet)

A policy written to authenticated doesn’t apply to a request made with the anon key and no session. In my test the profiles insert failed with 401 when signed out, and with 403 when signed in but inserting someone else’s id. Same code, same message, different status. If you see 401, check that the client really has a session at the moment of the insert. One way to end up without one, which I didn’t reproduce here: inserting the profile right after signUp() on a project with email confirmation on, where sign-up doesn’t return a session yet.

user_id isn’t sent, so the check fails

With a policy like with check ((select auth.uid()) = user_id), an insert that leaves user_id out writes null, and null = <your id> is not true. Signed in, insert({ task: 't' }) failed with 403 and 42501. Sending the column worked, but the cleaner fix is to stop relying on the client for it.

Make the database fill it in:

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

Upsert: the “(USING expression)” version

upsert() is an insert that can turn into an update. With INSERT and SELECT policies but no UPDATE policy:

  • The first upsert() (new row) returned 201.
  • The second upsert() on the same id returned 403 with:
new row violates row-level security policy (USING expression) for table "profiles"

Related trap: a plain .update() with no UPDATE policy did not error at all. It returned 200, changed nothing, and gave back an empty list.

Add an UPDATE policy. It needs using (which rows you may touch) and with check (what they may become):

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

Storage uploads

Storage keeps its files as rows in storage.objects, so uploads go through RLS too. A bucket with no policy on that table refused an upload with:

StorageApiError: new row violates row-level security policy
status: 400, statusCode: '403'

There’s no table name in this one, and the HTTP status (400) doesn’t match the statusCode field (403). Once uploads worked, uploading to an existing name with upsert: true failed again with code: 'AccessDenied' and the same message. Overwriting replaces an existing row, and an INSERT policy only covers new ones.

Policies on storage.objects, scoped to the bucket. Insert for new uploads:

create policy "upload avatars" on storage.objects
  for insert to anon, authenticated
  with check (bucket_id = 'avatars');

And for upsert: true, SELECT and UPDATE as well:

create policy "read avatars" on storage.objects
  for select to anon, authenticated
  using (bucket_id = 'avatars');

create policy "overwrite avatars" on storage.objects
  for update to anon, authenticated
  using (bucket_id = 'avatars');

“permission denied for table”: the October 30 change

Supabase’s API used to give anon, authenticated and service_role full access to every new table in public automatically, leaving RLS to do the filtering. That automatic grant was switched off for new projects on May 30, 2026. Per Supabase’s changelog, it applies to all existing projects on October 30, 2026. Tables that already exist keep their grants; tables you create after that don’t get any.

The local CLI has the same switch (auto_expose_new_tables in supabase/config.toml). I turned it off and created an orders table with RLS enabled and working policies. Every call failed anyway:

status: 401 Unauthorized
code: '42501'
message: 'permission denied for table orders'
hint: 'Grant the required privileges to the current role with: …'

Two things surprised me:

  • Policies don’t matter here. Postgres checks table privileges before it ever looks at RLS. With the grants added and the INSERT policy dropped, the error switched to the RLS message. So the order is: grant first, then policy.
  • service_role fails too. The service key bypasses RLS, but not grants. A server-side select() with the service key returned 403, with a hint naming the privilege it lacked.

The hint is worth reading: it names the exact privileges that call needed. For the anon read it was GRANT SELECT ON public.orders TO anon;, and insert().select() as a signed-in user asked for GRANT SELECT, INSERT ON public.orders TO authenticated;.

Grant the API roles access to the new table, then keep RLS on:

grant select on public.orders to anon;
grant select, insert, update, delete on public.orders to authenticated;
grant select, insert, update, delete on public.orders to service_role;
alter table public.orders enable row level security;

If you create tables in migrations, put the grants in the same migration as the create table. Otherwise the table works on your old project and fails on the next one.

What didn’t work

How this was tested

A local Supabase stack (CLI 2.120.0: Postgres 17.11, PostgREST 16.4, GoTrue 2.197.0, Storage 1.79.36) in Docker Desktop on Windows 11, called from Node 24.14.0 with @supabase/supabase-js 2.117.2. Each case was a table built in a migration with exactly one thing missing, called with the publishable (anon) key, as a freshly signed-up user, and with the service key. Every fix was applied on its own, and the same call rerun. The “permission denied” cases used auto_expose_new_tables = false locally, which is the setting Supabase applies to hosted projects. I didn’t run them against a hosted project, and I didn’t test the GraphQL API, which the same change also affects.

The code for every case above is public, so you can run it yourself: supabase-42501 in nk-repro.

— N.K., end of entry No.047

Useful? Pass it on:Post on XFollow @EmotionalMatter

Related entries

  1. No.052

    Prisma P1001: Can't reach database server at localhost:5432

    P1001 prints the same words for five different problems, Docker and Supabase included. Each one reproduced, plus a one-line check that tells them apart.

    > Error: P1001: Can't reach database server at `localhost:5432`

    DATABASE5 min
  2. No.049

    Fix: @prisma/client did not initialize yet (Prisma 6 and 7)

    The error says run prisma generate. Why it keeps coming back on Windows, npm 12 and CI, reproduced, plus the errors Prisma 7 throws instead.

    > D:\project\node_modules\.prisma\client\default.js:43

    DATABASE4 min
  3. No.048

    prisma generate not working: "No command registered" (Prisma 8)

    npm now installs a Prisma 8 release candidate as prisma@latest. It has no generate, migrate dev or db push. Reproduced, with the fix for Docker and CI.

    > {"kind":"result","envelope":{"ok":false,"commandId":"","error":{"code":"CLI.UNKNOWN_COMMAND","severity":"error","summary":"No command registered for `generate`","nextActions":[{"kind":"run-command","label":"List every command","command":"prisma --help"}]}}}

    DATABASE4 min

Post card · Newsletter

Get the next fix in your inbox.

One short email when a new entry is published. No spam, never shared, and you can leave any time.

— Nilay

or follow by RSSor on X

By subscribing you agree to the privacy note. One click to leave.

tip: paste the exact error text