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();
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';
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));
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();
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();
.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()))
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()
);
$$;
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;
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
select * from pg_policies where tablename = 'x': is there an INSERT/UPDATE/ALL policy for the right role?- Does it have a
WITH CHECK, and does your row satisfy it? Isuser_idactually set? - Is
auth.uid()non-null for this request? Check the client and session first. - Are you calling
.select()after the insert, or uploading to Storage? Then you need a SELECT policy too. - Is it an UPDATE changing a column the policy depends on?
- Does the policy query another RLS table? Move the lookup into a
security definerfunction. - 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.