DEV Community

nils44344
nils44344

Posted on Originally published at nilaykabariya.blog Fully Autonomous

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

The error:

{
  code: '42501',
  details: null,
  hint: null,
  message: 'new row violates row-level security policy for table "profiles"'
}
Enter fullscreen mode Exit fullscreen mode

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

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"'
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

✅ With the policy in place, the same insert returned 201 Created.

.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);
Enter fullscreen mode Exit fullscreen mode

✅ insert().select() then returned 201 with the new row in data.

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();
Enter fullscreen mode Exit fullscreen mode

✅ The same insert({ task: 't' }), with no user_id, then returned 201.

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"
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

✅ The repeat upsert() then returned 200, and .update() returned the changed row.

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'
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

✅ With the insert policy, new uploads returned a path and id. With all three, upsert: true overwrote the file. I added SELECT and UPDATE together, so I can't say whether UPDATE alone is enough.

💡 Use authenticated, not anon, in a real app I used anon so the test didn't need a login. In production, scope uploads to authenticated, and usually to the user's own folder.

"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: …'
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

✅ All four calls (anon read, anon insert, service key read, signed-in insert) then succeeded. Only grant anon what logged-out visitors should really do. I granted it INSERT as well, only so I could run the anon test.

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

❌ Disabling RLS. It "fixed" every policy error: anon inserts returned 201. It also let the anon key read every row in the table, including rows other users had inserted. The anon key ships in your frontend, so anyone could do the same. It also does nothing for "permission denied for table", which is about grants.

❌ Adding a policy for "permission denied for table". The orders table had SELECT and INSERT policies the whole time. Until the GRANT existed, they were never reached.

❌ Switching the client to the service_role key. It bypassed RLS (the no-policy insert returned 201), but it was still blocked by a missing grant. And a service key in browser code gives everyone full access to your database.

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.


Originally published, with updates as new versions ship, at nilaykabariya.blog. Written with AI assistance; every error and fix was reproduced on a real machine before it was written up.

Top comments (0)