How to build a multi-tenant app on Supabase (teams and orgs)
A working pattern for teams and organizations on Supabase: the tables you need, RLS based on membership, roles, creating an org safely and testing isolation.
Leer en español →
The standard way to build a multi-tenant (teams or organizations) app on Supabase is one shared database with an organizations table, a memberships table that links users to organizations with a role, and an org_id column on every table that holds tenant data. Row Level Security then allows a row only when its org_id is one of the user's organizations, using a small security definer helper function kept in a schema the API doesn't expose. You don't need a separate database or Supabase project per customer.
This guide walks through the tables, the helper function, the policies, the tricky "create an organization" step, roles, and how to prove one customer can't see another's data.
What tables do I need?
Three kinds: the tenants, the link between users and tenants, and your data.
create table public.organizations (
id uuid primary key default gen_random_uuid(),
name text not null,
created_by uuid not null default auth.uid() references auth.users(id),
created_at timestamptz not null default now()
);
create table public.memberships (
org_id uuid not null references public.organizations(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
role text not null check (role in ('owner', 'admin', 'member')),
primary key (org_id, user_id)
);
create table public.projects (
id uuid primary key default gen_random_uuid(),
org_id uuid not null references public.organizations(id) on delete cascade,
name text not null
);
create index memberships_user_id_idx on public.memberships (user_id);
create index projects_org_id_idx on public.projects (org_id);
Every table with customer data gets an org_id, including child tables (tasks inside projects, comments inside tasks). A direct column makes policies simpler and faster than joining up to the parent.
Why do I need a helper function?
Your policies need to ask "which organizations does this user belong to?", which means reading memberships. If memberships has its own RLS policy that also reads memberships, Postgres reports infinite recursion detected in policy for relation "memberships". The usual fix is a function that reads memberships with its owner's rights.
The Supabase RLS guide sets two conditions for security definer functions: pin search_path = '' and never create them in an exposed schema such as public. So create a private schema:
create schema if not exists private;
grant usage on schema private to authenticated;
create or replace function private.user_org_ids()
returns setof uuid
language sql stable security definer
set search_path = ''
as $
select org_id from public.memberships
where user_id = (select auth.uid())
$;
create or replace function private.user_admin_org_ids()
returns setof uuid
language sql stable security definer
set search_path = ''
as $
select org_id from public.memberships
where user_id = (select auth.uid()) and role in ('owner', 'admin')
$;
Because the function returns a set and doesn't depend on the row, using it as org_id in (select private.user_org_ids()) lets Postgres run it once per query.
What do the policies look like?
Turn on RLS on all three tables, then add policies. For tenant data:
alter table public.projects enable row level security;
create policy "Members read their orgs' projects"
on public.projects for select to authenticated
using ( org_id in (select private.user_org_ids()) );
create policy "Members create projects in their orgs"
on public.projects for insert to authenticated
with check ( org_id in (select private.user_org_ids()) );
create policy "Members update their orgs' projects"
on public.projects for update to authenticated
using ( org_id in (select private.user_org_ids()) )
with check ( org_id in (select private.user_org_ids()) );
create policy "Admins delete projects"
on public.projects for delete to authenticated
using ( org_id in (select private.user_admin_org_ids()) );
The with check on update matters: without it, a member could move a project into another organization by changing its org_id.
For memberships, members can see who else is in their organizations, and only admins can add or remove people:
alter table public.memberships enable row level security;
create policy "Members see their orgs' members"
on public.memberships for select to authenticated
using ( org_id in (select private.user_org_ids()) );
create policy "Admins manage members"
on public.memberships for all to authenticated
using ( org_id in (select private.user_admin_org_ids()) )
with check ( org_id in (select private.user_admin_org_ids()) );
Remember the Data API grants as well. New Supabase projects since May 30, 2026 don't expose new tables automatically, and Supabase says existing projects follow on October 30, 2026, so add grant select, insert, update, delete on ... to authenticated for each table in the migration that creates it.
How does someone create an organization?
This is where most first attempts break. The user inserts an organization, but they're not a member yet, so they can't read it back, and they can't add the first membership because only admins can. Solve it with a trigger that adds the creator as owner:
create or replace function private.add_creator_as_owner()
returns trigger
language plpgsql security definer
set search_path = ''
as $
begin
insert into public.memberships (org_id, user_id, role)
values (new.id, new.created_by, 'owner');
return new;
end;
$;
create trigger organizations_add_owner
after insert on public.organizations
for each row execute function private.add_creator_as_owner();
alter table public.organizations enable row level security;
create policy "Users create orgs as themselves"
on public.organizations for insert to authenticated
with check ( created_by = (select auth.uid()) );
create policy "Members and creators read orgs"
on public.organizations for select to authenticated
using (
id in (select private.user_org_ids())
or created_by = (select auth.uid())
);
The created_by condition in the select policy lets the app read the new organization straight after inserting it.
How should invitations work?
Keep invitations in their own table (org_id, email, role, a random token, an expiry date) that only admins can insert into. Accepting an invite should happen on the server, in an Edge Function or a security definer function, which checks the token, checks that the signed-in user's email matches, creates the membership and deletes the invite. Never let the browser insert its own membership row.
Should I put the organization in the JWT?
You can, with a Custom Access Token hook that adds the user's organizations or roles as claims. The trade-off: claims only refresh when the session token does, so removing someone from an organization doesn't take effect until their token renews. For most apps, the membership lookup above is fast enough with the indexes in place, and it's always current.
How do I prove tenants are isolated?
- Create two organizations with one user each, plus a third user who belongs to both.
- Sign in as the first user and try to read, update and delete the second organization's projects by ID, both in the app and with direct API calls.
- Try inserting a project with the other organization's
org_id. It must fail. - Remove the shared user from one organization and confirm their access disappears.
Run these again whenever a new table is added. Our guide on a user seeing another user's data covers what to look for in the app itself, and RLS policy examples shows how to test policies in SQL. For Storage, use the org_id as the first folder of each file path and apply the same helper in the storage.objects policies.
Quick answers
Should each customer get their own Supabase project? Rarely. It multiplies migrations, keys and costs. Separate projects make sense mainly when a contract requires physical separation.
Where does billing go? On the organization, not the user. One Stripe customer per organization; see Stripe subscriptions in your app.
How do users switch organizations? The UI keeps the selected organization and filters by it. RLS still decides what they can see, so a tampered selection shows nothing extra.
Can a user be in several organizations? Yes, that's what the memberships table is for.
Need a second look at your tenant setup?
A Supabase security review tests isolation table by table, and you can contact us to talk through your design. The free App Health Check is a quick place to start.


