Supabase RLS blocks your inserts? Six causes and how to fix each
"new row violates row-level security policy" usually comes down to one of six things. Here is how to find which one, and fix it without turning RLS off.
You turned on Row Level Security, wrote a policy that looks right, and now every insert from your app fails with:
new row violates row-level security policy for table "todos"
The good news: this error almost always comes from one of six causes. Go through them in order and you will find yours in a few minutes.
1. There is no INSERT policy at all
When RLS is on, everything is denied until a policy allows it. A lot of apps have a policy for SELECT (often copied from a tutorial) but nothing for INSERT.
Check what you have:
select policyname, cmd, roles, qual, with_check
from pg_policies
where tablename = 'todos';
If there is no row with cmd = 'INSERT' (or ALL), add one:
create policy "users insert own rows"
on public.todos for insert
to authenticated
with check (auth.uid() = user_id);
2. The policy uses USING instead of WITH CHECK
For inserts, Postgres only looks at with check. A using clause is for rows that already exist (select, update, delete). If you wrote the insert rule into using, Postgres either rejects the policy or the insert still fails.
Rule of thumb:
- select / delete:
using (...) - insert:
with check (...) - update: both,
usingfor which rows you may touch,with checkfor what they may become
3. user_id is empty, so the check can never pass
Your policy says auth.uid() = user_id, but the client sends the row without user_id. Null is not equal to anything, so the check fails.
Either send it from the client, or better, let the database fill it in:
alter table public.todos
alter column user_id set default auth.uid();
Now the client can insert { title: "Buy milk" } and the row is still owned by the right user.
4. The request is not actually logged in
auth.uid() returns null when the request comes in as anon. This happens more often than you would think:
- the user's session expired and the client did not refresh it
- you call Supabase from a server route or an edge function with the anon key, but never pass the user's access token
- the policy says
to authenticated, while the request arrives asanon
On the server, create the client with the user's token, so Postgres knows who is asking:
const supabase = createClient(url, anonKey, {
global: { headers: { Authorization: `Bearer ${accessToken}` } }
})
5. insert().select() also needs a SELECT policy
This one catches almost everybody. If you write:
const { data, error } = await supabase.from('todos').insert({ title }).select()
Supabase inserts the row and then reads it back. Reading it back is a select, so you also need a select policy for that row. Without it the whole call fails with the same RLS error, even though your insert policy is fine.
Either add the select policy:
create policy "users read own rows"
on public.todos for select
to authenticated
using (auth.uid() = user_id);
or drop the .select() if you do not need the row back.
6. A related table has its own rules
If your insert fires a trigger that writes into another table, or the policy itself runs a subquery on another table (for example "is this user a member of this team?"), those tables have RLS too. The check fails because the subquery returns nothing for this user.
Two ways out: give the user read access to the rows the check needs, or move the check into a security definer function that runs with the owner's rights. Keep that function small and only return what the check needs.
How to test a policy without guessing
The SQL editor in the dashboard runs as a superuser, which skips RLS. So a query that works there tells you nothing. Pretend to be the user instead:
begin;
set local role authenticated;
set local request.jwt.claims = '{"sub": "PASTE-A-REAL-USER-ID"}';
insert into public.todos (title) values ('test');
rollback;
If this fails, the problem is in your policies. If it works, the problem is in how your app sends the request (causes 4 and 5).
What not to do
Do not "fix" it by turning RLS off, and do not put the service_role key in the browser. Both make the error go away by giving everyone access to every row. The first person who opens the network tab gets your whole database.