Home / RLS / Multi-tenant RLS with workspaces
Supabase RLS · Multi-tenant
Multi-tenant RLS with workspaces in Supabase
Scope rows to workspace_id and check membership through a security-definer function (user_workspace_ids()) so policies stay simple and don't recurse. Full pattern below.
01 The shape
For teams, a row belongs to a workspace, and a user can touch it if they're a member. You need a membership table and a way to ask "which workspaces is this user in?" from inside a policy — without triggering infinite recursion.
migration.sql
create table workspace_members ( workspace_id uuid references workspaces, user_id uuid references auth.users, primary key (workspace_id, user_id) ); alter table workspace_members enable row level security; create policy "read own memberships" on workspace_members for select using (user_id = (select auth.uid()));
02 Resolve membership once (security definer)
A policy on projects that queries workspace_members, which itself has RLS, can recurse. The fix: a security definer function that reads membership with the definer's rights, so the policy stays a simple IN check. Pin search_path to empty for safety.
migration.sql
create or replace function public.user_workspace_ids() returns setof uuid language sql security definer stable set search_path = '' as $$ select workspace_id from public.workspace_members where user_id = auth.uid() $$;
03 Scope the tenant tables
migration.sql
alter table projects enable row level security; create policy "members read workspace projects" on projects for select using (workspace_id in (select public.user_workspace_ids())); create policy "members write workspace projects" on projects for insert with check (workspace_id in (select public.user_workspace_ids()));
For role-gated writes (only admins delete), add the member's role to the function's return and check it in the policy.
04 Prove it
Catch this before it ships
airlock-rls is a CI gate that fails your build when a table ships exposed or a policy is permissive — the same class of bug, caught on the pull request instead of in prod.
Or start from nextjs-supabase-starter — auth + a table with RLS + an isolation test, so a fresh table is safe by default.
Grab the Supabase RLS cheat sheet
The golden rules, the footguns that leak in prod, correct policy snippets, and the isolation test — on one page.
FAQ
Why a security-definer function instead of a subquery?
A policy that directly queries an RLS-protected membership table can recurse (the membership table's own policy re-evaluates). A security-definer function reads membership with elevated rights and returns a plain set of ids, so the policy is a simple IN check with no recursion.
Is security definer safe here?
Yes, when you pin search_path to '' and keep the function's body minimal and read-only. It only returns the caller's own workspace ids — it never exposes another user's memberships.