Home / RLS / Multi-tenant RLS with workspaces

Supabase RLS · Multi-tenant

Multi-tenant RLS with workspaces in Supabase

Postgres 15 · Supabase✓ code tested against a real database
TL;DR

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

a member reads their workspace's projects
a non-member gets 0 rows for that workspace
a member cannot insert a project into a workspace they don't belong to
◆ FREE · MIT · npm + GitHub Action

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.

npx airlock-rls

Or start from nextjs-supabase-starter — auth + a table with RLS + an isolation test, so a fresh table is safe by default.

FREE PDF

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.