Build tenant isolation in Supabase with organization membership, row-level security policies, service-role boundaries and repeatable cross-tenant tests.
By Marc Illy, Founder of Cognival ยท 2026-04-29
A multi-tenant Supabase application stores data for several organizations in one Postgres project while enforcing which users and services may access each row. The security boundary must live in the database as well as the application UI. Hiding another tenant's records in frontend code is not isolation.
Supabase recommends enabling Row Level Security on tables in exposed schemas and defining policies for the roles that access them. Grants decide which operations a role may attempt; RLS policies decide which rows those operations may affect. The example below shows one practical organization-membership pattern. Adapt it to your application and obtain a security review before using it with sensitive or regulated data.
create table public.organizations (
id uuid primary key default gen_random_uuid(),
name text not null
);
create table public.organization_members ( organization_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 (organization_id, user_id) );
create table public.projects ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, name text not null, created_at timestamptz not null default now() );
create index projects_organization_id_idx on public.projects (organization_id);
create index organization_members_user_id_idx on public.organization_members (user_id);
Every tenant-owned table needs an immutable or tightly controlled organization_id. Index tenant keys used by policies and common queries.
create schema if not exists private;
create or replace function private.is_organization_member(target_organization_id uuid) returns boolean language sql stable security definer set search_path = '' as $$ select exists ( select 1 from public.organization_members om where om.organization_id = target_organization_id and om.user_id = (select auth.uid()) ); $$;
revoke all on function private.is_organization_member(uuid) from public; grant execute on function private.is_organization_member(uuid) to authenticated;
Keep security-definer helpers outside exposed schemas, set an explicit search path, and review the function owner and grants. A function that bypasses policy checks can become a privilege-escalation path if it accepts unsafe input or exposes more than a boolean authorization decision.
alter table public.projects enable row level security;
revoke all on table public.projects from anon, authenticated; grant select, insert, update, delete on table public.projects to authenticated;
create policy "members can read organization projects" on public.projects for select to authenticated using ((select private.is_organization_member(organization_id)));
create policy "members can create organization projects" on public.projects for insert to authenticated with check ((select private.is_organization_member(organization_id)));
create policy "members can update organization projects" on public.projects for update to authenticated using ((select private.is_organization_member(organization_id))) with check ((select private.is_organization_member(organization_id)));
create policy "members can delete organization projects" on public.projects for delete to authenticated using ((select private.is_organization_member(organization_id)));
This example permits every organization member to mutate projects. A real system may need role-specific policies so only owners or admins can create, update, or delete. Encode that decision in reviewed database logic rather than trusting a role label sent from the browser.
Supabase service credentials can bypass RLS and must never be shipped to a browser, mobile client, public repository, or customer-controlled environment. Use them only in trusted server-side jobs with their own authorization, audit logging, and minimal data access.
RLS does not protect a request that deliberately runs with a bypass role. Treat every service-role operation as privileged administration.
Create two organizations and two users. Then test each operation using the same client path the application uses.
| Test | Expected result | |---|---| | User A lists projects | Only Organization A rows return | | User A requests a known Organization B project ID | No row returns or access is denied | | User A inserts a project with Organization B's ID | Insert is denied | | User A updates a project and changes its organization to B | Update is denied | | User A deletes an Organization B project | Delete is denied | | Anonymous client reads projects | No rows return unless a deliberate public policy exists | | Normal browser client attempts service-role behavior | Impossible because no service credential is present |
Automate these tests. Supabase documents database testing and RLS-policy testing through the CLI and pgTAP, and application-level tests can use the normal Supabase client with separate test users.
USING policy exists but the matching WITH CHECK boundary is wrong.Multi-tenancy is an authorization design, not only a schema choice. Put the tenant key on every protected row, enforce membership with grants and RLS, keep privileged credentials server-side, and prove isolation with repeatable cross-tenant tests.
Need a second set of eyes before launch? Book a Cognival architecture review for a policy map, service-role inventory, and tenant-boundary test plan.
Primary editor sources:
30-min strategy call. No pitch, real look at your stack.
Book a strategy call โ