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
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' })returned201 Created.insert({ body: 'x' }).select()returned401and 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) returned201. - The second
upsert()on the sameidreturned403with:
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_rolefails too. The service key bypasses RLS, but not grants. A server-sideselect()with the service key returned403, 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