"new row violates row-level security policy" is probably the most common error people hit on their first Supabase + Next.js app. It usually shows up right after you enable RLS, which is the correct thing to do. The fix is almost never "turn RLS off". You need a small, predictable set of policies and a client that actually sends the user's JWT.
This is the checklist I use for a typical multi-tenant SaaS: users, profiles, workspaces, members and app data.
First: how RLS decides
- RLS on with no policies means nobody gets in (except the service role). That's why inserts suddenly fail after you flip the switch.
-
Each operation needs its own policy. A
selectpolicy does not allowinsert, and anupdatepolicy does not coverupsertinserts. -
usingfilters existing rows;with checkvalidates new or changed rows. Inserts only usewith check. Updates use both. -
PostgREST returns the inserted row by default. If you have an insert policy but no matching select policy, the insert can fail on the return step. Add a select policy or call
.insert(row, { returning: 'minimal' })(in supabase-js v2, just don't chain.select()). -
Grants still apply. A missing grant raises
42501before any policy runs.
The 7 policies
1. Profiles: read your own
create policy "profiles: read own"
on public.profiles for select
to authenticated
using ((select auth.uid()) = id);
Wrapping auth.uid() in (select ...) lets Postgres cache it per statement, which matters on large tables.
2. Profiles: created by a trigger, not the client
Don't insert profile rows from onAuthStateChange in the browser. Use a trigger on auth.users:
create function public.handle_new_user()
returns trigger language plpgsql security definer set search_path = public as $$
begin
insert into public.profiles (id) values (new.id);
return new;
end; $$;
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
No client insert policy is needed, which is one less thing to get wrong.
3. Profiles: update your own, can't change owner
create policy "profiles: update own"
on public.profiles for update
to authenticated
using ((select auth.uid()) = id)
with check ((select auth.uid()) = id);
4. Membership helper
Most SaaS data belongs to a workspace, not a user. Put the membership check in one security definer function so policies stay short and you avoid recursive policies on the members table:
create function public.is_member(ws uuid)
returns boolean language sql stable security definer set search_path = public as $$
select exists (
select 1 from public.workspace_members
where workspace_id = ws and user_id = auth.uid()
);
$$;
5. Workspace data: read and write if member
create policy "projects: member read"
on public.projects for select to authenticated
using (public.is_member(workspace_id));
create policy "projects: member insert"
on public.projects for insert to authenticated
with check (public.is_member(workspace_id));
create policy "projects: member update"
on public.projects for update to authenticated
using (public.is_member(workspace_id))
with check (public.is_member(workspace_id));
6. Owner-only delete
Deletes are where accidents happen. Restrict them to owners:
create policy "projects: owner delete"
on public.projects for delete to authenticated
using (exists (
select 1 from public.workspace_members m
where m.workspace_id = projects.workspace_id
and m.user_id = (select auth.uid())
and m.role = 'owner'
));
7. Storage objects
Storage uploads are inserts into storage.objects followed by a returning. You need insert and select:
create policy "avatars: upload own folder"
on storage.objects for insert to authenticated
with check (bucket_id = 'avatars' and (storage.foldername(name))[1] = (select auth.uid())::text);
create policy "avatars: read own folder"
on storage.objects for select to authenticated
using (bucket_id = 'avatars' and (storage.foldername(name))[1] = (select auth.uid())::text);
The Next.js side: make sure the JWT arrives
Half of "RLS is broken" reports are really "the request was anonymous".
- In the App Router, use
@supabase/ssrandcreateServerClientwith cookiegetAll/setAllin Server Components, Route Handlers and middleware. Don't mix it with the old auth-helpers package. - In middleware, write refreshed cookies to both the request and the response, or the next request will look logged out.
- Call
supabase.auth.getUser()on the server, notgetSession(), when you make auth decisions. - Never ship the service role key to the browser. If something only works with the service role, that's a missing policy, not a reason to use the service key.
Test policies, don't eyeball them
Put pgTAP tests in supabase/tests/ and run supabase test db. For each table, assert:
- anon can't select, insert, update or delete
- a member can do what they should
- a non-member gets zero rows on select and
42501on insert - an update can't move a row to another workspace
One catch: a denied update filtered by using raises nothing. It just matches zero rows. Assert with returning and check the result, or your test will pass on a write that never happened.
Generated code still needs this
If you scaffold apps with an AI builder, check the generated migrations against this list before launch. Tools that output a real Next.js + Supabase codebase, like Massvai, make that easy because you can read and edit the SQL. Whatever generated it, the rule is the same: one policy per operation, a membership helper, and tests.
Quick debugging order
- Is RLS enabled and are the grants there?
- Is there a policy for this exact operation?
- Does the insert also need a select policy for
returning? - Is the request authenticated? Log
auth.uid()withselect auth.uid()from the same client. - Does
with checkmatch the values you're actually sending, for exampleuser_id?
Get those five right and the error mostly goes away.
Top comments (0)