Supabase RLS for a Next.js SaaS: The 7 Policies You Actually Need

2026-10-11来源 dev.to SecurityEnglish

"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 select policy does not allow insert, and an update policy does not cover upsert inserts.
  • using filters existing rows; with check validates new or changed rows. Inserts only use with 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 42501 before 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/ssr and createServerClient with cookie getAll/setAll in 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, not getSession(), 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 42501 on 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

  1. Is RLS enabled and are the grants there?
  2. Is there a policy for this exact operation?
  3. Does the insert also need a select policy for returning?
  4. Is the request authenticated? Log auth.uid() with select auth.uid() from the same client.
  5. Does with check match the values you're actually sending, for example user_id?

Get those five right and the error mostly goes away.

原文:https://www.a8q9.cn/en/article/supabase-rls-for-a-next-js-saas-the-7-policies-you-actually-need-59893b.html

相关文章