Supabase RLS for a Next.js SaaS: The 7 Policies You Actually Need
"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.