DEV Community

Joan Comadran
Joan Comadran

Posted on Originally published at joancomadran.dev

Supabase "new row violates row-level security policy": every cause and the fix

If Supabase is throwing new row violates row-level security policy for table "your_table" (code 42501, HTTP 403), Row Level Security is on and the row you're writing didn't pass any policy's WITH CHECK. Nine times out of ten the fix is one of these two:

-- 1. The table has no INSERT policy. Add one:
create policy "insert own rows" on public.notes
  for insert to authenticated
  with check ((select auth.uid()) = user_id);

-- 2. The row doesn't carry the user's id. Let the database fill it:
alter table public.notes
  alter column user_id set default auth.uid();
Enter fullscreen mode Exit fullscreen mode

If that didn't do it, one of the other four causes below is yours. I'll show how to tell which, with the SQL for each. The examples come from the RLS I've written for two production Supabase apps: Claient, a multi-tenant WhatsApp lead agent where every row belongs to an account, and pollitics.es, a public polling site.

What the error actually means

RLS is default-deny per command. Enabling it on a table without policies blocks everything for anon and authenticated. A SELECT policy lets people read, but it says nothing about INSERT, UPDATE or DELETE. Each of those needs its own policy, or a for all policy.

Two clauses matter:

  • USING: which existing rows you can see, update or delete.
  • WITH CHECK: which new row values you're allowed to write (inserts, and the after-image of updates).

"new row violates…" always means a WITH CHECK evaluated to false or null for your row. It never means "the table doesn't exist" or "your key is wrong".

Cause 1: there is no INSERT policy

The classic one. You enabled RLS, added "Enable read access for authenticated users", and reads work, so you assume writes will too. They won't.

Check what you have:

select policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public' and tablename = 'notes';
Enter fullscreen mode Exit fullscreen mode

If no row has cmd = INSERT or ALL, add the policy from the top of this post. If you want one policy per table, for all with both clauses is fine. This is how every tenant table in Claient is written:

create policy leads_all on public.leads
  for all using (public.is_account_member(account_id))
  with check (public.is_account_member(account_id));
Enter fullscreen mode Exit fullscreen mode

Cause 2: user_id is null or not the caller

Your policy says with check (auth.uid() = user_id), but the client inserted { title } without user_id. Then auth.uid() = null is null, not true, and the row is rejected.

Don't make the client responsible for sending its own id. Set a default so it can omit the column:

alter table public.notes alter column user_id set default auth.uid();
Enter fullscreen mode Exit fullscreen mode

Keep the WITH CHECK anyway. A default only applies when the column is omitted, so the policy is what stops a client sending someone else's id.

Cause 3: the request isn't authenticated (auth.uid() is null)

The policy and the column are right, and it still fails. The usual culprit is that the request reached Postgres as anon, so auth.uid() is null and every auth.uid() = … check fails. That happens when:

  • a Server Action or Route Handler uses a client created with createClient(url, anonKey) and no session;
  • in Next.js with @supabase/ssr, the server client isn't reading the request cookies, or the session cookie expired and was never refreshed by middleware;
  • the insert runs before the session exists, e.g. right after signUp() with email confirmation still pending.

Quick test: run select auth.uid(), auth.role(); through the same client (as an RPC), or log await supabase.auth.getUser() right before the insert. If it's null or anon, fix the client, not the policy. On the server, build the client with createServerClient from @supabase/ssr and pass it the request cookies. That's how the dashboard in Claient does it.

If you're creating a profile row right after sign-up, don't insert from the client at all. Use a trigger on auth.users with a security definer function, which runs regardless of the session.

Cause 4: insert().select() without a SELECT policy

const { data, error } = await supabase.from('notes').insert({ title }).select();
Enter fullscreen mode Exit fullscreen mode

.select() turns the statement into INSERT … RETURNING *. Postgres must let you read the row you just wrote, so a table with an INSERT policy but no matching SELECT policy fails with the same "new row violates" message. It's confusing because the insert is the part that's allowed.

Fix: add a SELECT policy that covers the new row, or drop .select() if you don't need the row back.

The same thing explains the famous Storage version of the error. Uploads do an INSERT … RETURNING on storage.objects, so you need a SELECT policy on storage.objects for that bucket and path, not just an INSERT one. Supabase's troubleshooting note confirms it.

Cause 5: an UPDATE whose new row fails WITH CHECK

Updates are checked twice: USING on the old row, and WITH CHECK on the new one. If the UPDATE policy has no WITH CHECK, Postgres reuses the USING expression for it. So an update that changes a column your policy depends on, like moving a row to another account or user, is rejected with "new row violates…".

Usually that's exactly what you want: it stops users from writing a row into someone else's tenant. If it's blocking a legitimate update, look at which column in your SET the policy reads, and write an explicit WITH CHECK for the state the row is allowed to end up in. Don't reach for with check (true) unless you've thought through what it lets through.

Cause 6: a policy that can't see what it needs

Multi-tenant policies often check membership in another table:

using (exists (select 1 from account_members m
               where m.account_id = leads.account_id and m.user_id = auth.uid()))
Enter fullscreen mode Exit fullscreen mode

If account_members has RLS too, that subquery runs under the caller's policies. It can come back empty, making the check false, or recurse into itself if account_members' own policy does the same lookup. The fix I use in Claient is one security definer function that every policy calls:

create or replace function public.is_account_member(target_account uuid)
returns boolean
language sql
stable
security definer
set search_path = public
as $$
  select exists (
    select 1 from public.account_members m
    where m.account_id = target_account
      and m.user_id = auth.uid()
  );
$$;
Enter fullscreen mode Exit fullscreen mode

security definer makes the lookup run as the function's owner, so it doesn't depend on account_members' own policies and can't recurse. set search_path stops anyone from shadowing the table with their own. I go deeper into this pattern, including wrapping calls as (select fn()) so they run once per query instead of once per row, in the pre-launch security checklist for Next.js + Supabase.

Not this error: "permission denied for table"

Same 42501 code, different problem. permission denied for table notes (or for column status) means the role lacks a GRANT, which Postgres checks before RLS. No policy will fix it.

I hit this deliberately in Claient. Users could update their own feature request, and the RLS policy limited that to their own row. But RLS can't limit which columns you change, so someone could call PostgREST directly and mark their own request as "done". The fix was column-level grants:

revoke update on public.feature_requests from authenticated;
grant update (title, description) on public.feature_requests to authenticated;
Enter fullscreen mode Exit fullscreen mode

Now updating status as a user returns "permission denied for column", while the admin path still works.

When the service_role key is the right answer (and when it isn't)

The fastest way to make the error go away is the service_role (secret) key, which bypasses RLS completely. That's correct for server code that legitimately acts across users. In Claient, the WhatsApp webhook writes messages and leads for whichever tenant the message belongs to, and the Stripe webhook writes subscriptions. Those tables deliberately have no write policy for users, so only the server can write them. Some internal tables have RLS on with zero policies, which is deny-all for everyone but service_role.

It's the wrong answer in user-facing code. The key must stay server-only (never NEXT_PUBLIC_), and swapping it in to silence an error means that request no longer has any data isolation.

A 60-second checklist

  1. select * from pg_policies where tablename = 'x': is there an INSERT/UPDATE/ALL policy for the right role?
  2. Does it have a WITH CHECK, and does your row satisfy it? Is user_id actually set?
  3. Is auth.uid() non-null for this request? Check the client and session first.
  4. Are you calling .select() after the insert, or uploading to Storage? Then you need a SELECT policy too.
  5. Is it an UPDATE changing a column the policy depends on?
  6. Does the policy query another RLS table? Move the lookup into a security definer function.
  7. Does the message say "permission denied"? That's a GRANT, not RLS.

I build Supabase and AI products end to end, and RLS for multi-tenant apps is a recurring part of that. If you'd rather have someone get it right the first time, here's how I work.

FAQ

What does "new row violates row-level security policy" mean in Supabase?

It is Postgres error 42501, returned by Supabase as HTTP 403. Row Level Security is enabled on the table and the row you are writing did not pass any policy's WITH CHECK expression for that operation. Because RLS is default-deny per command, the most common reason is simply that the table has no INSERT (or UPDATE) policy for the role making the request.

How do I fix new row violates row-level security policy on insert?

Add an INSERT policy for the authenticated role with a WITH CHECK clause, for example: create policy "insert own rows" on public.notes for insert to authenticated with check ((select auth.uid()) = user_id). Then make sure the row actually carries the signed-in user's id, ideally with a column default of auth.uid(), and that the request is made with the user's session rather than only the anon key.

Why do I get the RLS error when I call insert().select()?

insert().select() runs INSERT ... RETURNING, and returning the row requires that the caller can also SELECT it. If the table has an INSERT policy but no SELECT policy that covers the new row, the whole statement fails with the same RLS error. Add a matching SELECT policy or drop the .select().

Should I use the service_role key to get around RLS errors?

Only on the server, and only for code that legitimately acts across users, such as webhooks or background workers. The service_role key bypasses RLS entirely, so it must never reach the browser. Using it to silence an RLS error in user-facing code removes your data isolation instead of fixing the policy.

Is "permission denied for table" the same as the RLS error?

No. Both use SQLSTATE 42501, but "permission denied for table" (or "for column") comes from missing GRANT privileges, which Postgres checks before RLS. "new row violates row-level security policy" means the grant was fine and a policy rejected the row. Fix the first with GRANT, the second with a policy.


Originally published on joancomadran.dev. I'm Joan, an AI engineer in Barcelona — work with me.

Top comments (0)