Ik heb drie multi-tenant SaaS-apps op Supabase gelanceerd in de afgelopen twee jaar. De eerste was een ramp. Niet omdat Supabase me in de steek liet -- dat deed het niet -- maar omdat ik fundamenteel misbegreep hoe Row Level Security (RLS) samenwerkt met query planning op schaal. De tweede was beter. De derde verwerkt 2.000+ tenants met query-tijden onder de 50ms over tabellen met miljoenen rijen.

Dit artikel is alles wat ik had willen weten voor dat eerste project. We gaan een echte multi-tenant schema van nul af aan bouwen, RLS-beleid opzetten dat werkelijk presteert, en de randgevallen behandelen die alleen opduiken als je echte traffic op je database krijgt.

Inhoudsopgave

Supabase RLS Multi-Tenant Production Schema Design Guide

Multi-tenancy-benaderingen in Supabase

Voordat we een enkele regel SQL schrijven, moeten we duidelijk krijgen welke drie benaderingen voor multi-tenancy er zijn en waarom één daarvan voor de meeste Supabase-projecten wint.

Benadering Isolatieniveau Complexiteit Kosten per tenant Geschikt voor
Database per tenant Hoogste Zeer hoog €23+/mnd per tenant Enterprise, compliance-intensief
Schema per tenant Hoog Hoog €4,50-13,50/mnd per tenant Mid-market SaaS
Gedeeld schema + RLS Gemiddeld Gemiddeld Centen per tenant De meeste SaaS-apps

Database-per-tenant is wat je zou gebruiken als je aan banken of gezondheidszorgbedrijven verkoopt die letterlijk aparte infrastructuur vereisen. Supabase maakt dit niet gemakkelijk -- je zou meerdere Supabase-projecten beheren.

Schema-per-tenant (PostgreSQL-schemas gebruiken zoals tenant_123.projects) klinkt aanlokkelijk maar wordt een onderhoudsachtermerrie. Elke migratie draait tegen elk schema. Ik heb dit eenmaal geprobeerd. Met 400 tenants duurde een eenvoudige ALTER TABLE-migratie 45 minuten.

Gedeeld schema met RLS is de zoetste plek voor 90% van SaaS-toepassingen. Eén set tabellen, één set migraties, en het RLS-beleid van PostgreSQL regelt de isolatie. Dat bouwen we hier.

Het fundament: tenant- en user-schema

Laten we beginnen met de kernelementaire tabellen. Ik ga je het schema tonen dat ik in productie gebruik, geen tutorialspeelgoed.

-- UUID-generatie inschakelen
create extension if not exists "uuid-ossp";

-- Tenants (organisaties, werkruimtes, hoe je ze ook noemt)
create table public.tenants (
  id uuid primary key default uuid_generate_v4(),
  name text not null,
  slug text unique not null,
  plan text not null default 'free' check (plan in ('free', 'pro', 'enterprise')),
  settings jsonb not null default '{}',
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

-- Lidmaatschap-verbindingstafel: verbindt auth.users met tenants
create table public.tenant_memberships (
  id uuid primary key default uuid_generate_v4(),
  tenant_id uuid not null references public.tenants(id) on delete cascade,
  user_id uuid not null references auth.users(id) on delete cascade,
  role text not null default 'member' check (role in ('owner', 'admin', 'member', 'viewer')),
  created_at timestamptz not null default now(),
  
  unique(tenant_id, user_id)
);

-- Kritisch: indexen waarop RLS-beleid zal leunen
create index idx_tenant_memberships_user_id on public.tenant_memberships(user_id);
create index idx_tenant_memberships_tenant_id on public.tenant_memberships(tenant_id);
create index idx_tenant_memberships_user_tenant on public.tenant_memberships(user_id, tenant_id);

Twee dingen om op te merken. Ten eerste gebruik ik een verbindingstafel (tenant_memberships) in plaats van een tenant_id direct op het gebruikersprofiel. Gebruikers kunnen tot meerdere tenants behoren -- dat is een echte vereiste voor bijna elke SaaS-app. Ten tweede zijn die indexen niet optioneel. Zonder ze doet elke RLS-controle een sequentiële scan op de lidmaatschapstafel. Ik heb gezien dat dit meer dan 200ms aan queries toevoegt eenmaal je een paar duizend lidmaatschappen hebt.

RLS-beleid ontwerpen dat de prestaties niet verslechtert

Dit is waar de meeste tutorials je falen. Ze tonen je een eenvoudig beleid als:

-- NIET DOEN IN PRODUCTIE
create policy "Gebruikers kunnen gegevens van hun tenant weergeven"
  on public.projects for select
  using (
    tenant_id in (
      select tenant_id from public.tenant_memberships
      where user_id = auth.uid()
    )
  );

Dit werkt. Het zal je testen doorstaan. En dan zul je het inzetten, 500 gebruikers krijgen, en je afvragen waarom je dashboard 4 seconden duurt om te laden.

Het probleem is dat PostgreSQL deze subquery voor elke single rij evalueert. De query planner kan kan het soms optimaliseren, maar doet het vaak niet -- vooral met JOINs erbij.

Het Security Definer Function-patroon

Hier is wat werkelijk in productie werkt:

-- Maak een functie die de tenant-ID's van de gebruiker retourneert
-- SECURITY DEFINER betekent dat het met de permissies van de functiemaker draait
-- Dit is kritiek: het omzeilt RLS op de lidmaatschapstafel zelf
create or replace function public.get_user_tenant_ids()
returns setof uuid
language sql
security definer
stable
set search_path = public
as $$
  select tenant_id 
  from public.tenant_memberships 
  where user_id = auth.uid();
$$;

-- Gebruik het nu in beleid
create policy "tenant_isolation_select"
  on public.projects for select
  using (tenant_id in (select public.get_user_tenant_ids()));

create policy "tenant_isolation_insert"
  on public.projects for insert
  with check (tenant_id in (select public.get_user_tenant_ids()));

create policy "tenant_isolation_update"
  on public.projects for update
  using (tenant_id in (select public.get_user_tenant_ids()))
  with check (tenant_id in (select public.get_user_tenant_ids()));

create policy "tenant_isolation_delete"
  on public.projects for delete
  using (tenant_id in (select public.get_user_tenant_ids()));

Waarom is dit sneller? Het STABLE-sleutelwoord vertelt PostgreSQL dat de functie hetzelfde resultaat binnen een enkel statement retourneert. De planner kan het eenmaal aanroepen en het resultaat hergebruiken. In mijn benchmarks snijdt dit de RLS-overhead met 60-80% voor queries die meerdere rijen aanraken.

De search_path-valstrik

Zie dat set search_path = public op de functie? Dat is niet optioneel. Zonder het zou een kwaadwillende gebruiker mogelijk een functie in een ander schema kunnen maken die auth.uid() in de schaduw stelt en je beveiliging omzeilt. Het Supabase-team heeft hierover geschreven, maar het is gemakkelijk te missen.

Supabase RLS Multi-Tenant Production Schema Design Guide - architecture

Het tenant_id-patroon: het goed doen

Elke tabel die tenantspecifieke gegevens bevat, heeft een tenant_id-kolom nodig. Geen uitzonderingen. Zelfs als de tabel een vreemde sleutel naar een ander tenantscoped tafel heeft. Hier is waarom:

-- Je projects-tafel
create table public.projects (
  id uuid primary key default uuid_generate_v4(),
  tenant_id uuid not null references public.tenants(id) on delete cascade,
  name text not null,
  created_at timestamptz not null default now()
);

-- Taken behoren tot projecten. Je zou kunnen denken dat tenant_id hier overbodig is.
create table public.tasks (
  id uuid primary key default uuid_generate_v4(),
  tenant_id uuid not null references public.tenants(id) on delete cascade,
  project_id uuid not null references public.projects(id) on delete cascade,
  title text not null,
  status text not null default 'todo',
  created_at timestamptz not null default now()
);

-- Index voor RLS + veelvoorkomende querypatronen
create index idx_projects_tenant on public.projects(tenant_id);
create index idx_tasks_tenant on public.tasks(tenant_id);
create index idx_tasks_project on public.tasks(project_id);
create index idx_tasks_tenant_project on public.tasks(tenant_id, project_id);

Ja, tenant_id op tasks is gedenormaliseerd. Ja, het is de juiste beslissing. Zonder het zou het RLS-beleid op tasks moeten JOINEN met projects om de tenant te controleren -- en die JOIN gebeurt op elke query. Met de gedenormaliseerde tenant_id is de RLS-controle een simpele indexzoeking.

Ik forceer consistentie met een trigger:

create or replace function public.verify_tenant_consistency()
returns trigger
language plpgsql
as $$
begin
  if NEW.tenant_id != (
    select tenant_id from public.projects where id = NEW.project_id
  ) then
    raise exception 'tenant_id mismatch: taak-tenant stemt niet overeen met project-tenant';
  end if;
  return NEW;
end;
$$;

create trigger check_task_tenant
  before insert or update on public.tasks
  for each row execute function public.verify_tenant_consistency();

JWT-claims versus databasezoekopdrachten

Supabase laat je aangepaste claims in het JWT-token insluiten. Sommige mensen gebruiken dit om de huidige tenant-ID op te slaan:

-- Lezen van JWT (snel, maar heeft voorbehouden)
auth.jwt() -> 'app_metadata' ->> 'current_tenant_id'

Versus dit elke keer vanuit de database opzoeken:

-- Databasezoeking (langzamer, maar altijd actueel)
select tenant_id from tenant_memberships where user_id = auth.uid()
Aspect JWT-claims Databasezoeking
Snelheid ~0,1ms ~1-5ms
Versfheid Verouderd tot token-vernieuwing Altijd actueel
Omschakeling multi-tenant Token-vernieuwing vereist Onmiddellijk
Veiligheid bij intrekking Vertraging tot JWT vervalt Onmiddellijk
Implementatiecomplexiteit Hoger (Edge Function nodig) Lager

Mijn aanbeveling: gebruik databasezoekopdrachten met het security definer-functiepatroon voor de meeste apps. Het prestatieverschil is verwaarloosbaar als je goede indexen hebt, en je vermijdt een hele klasse van bugs rond verouderde tokens.

Als je 10.000+ gelijktijdige gebruikers bedient en milliseconden besparen belangrijk is, plaats dan de actieve tenant-ID in de JWT. Je hebt een Supabase Edge Function (of een hook) nodig om de claim in te stellen wanneer gebruikers wisselen van tenants, en je moet de token-vernieuwingsstroom op de client verwerken.

Cross-tenant-bewerkingen verwerken

Sommige bewerkingen moeten legitiem tenantgrenzen overschrijden. Admin-dashboards, factureringssystemen, analyseaggregatie. Hier is hoe je ze veilig verwerkt.

Service Role Key (Sparsaam gebruiken)

De Supabase service role key omzeilt RLS volledig. Gebruik het alleen in server-side code -- blootstel het nooit aan de client.

// Server-side only (Next.js API-route, Edge Function, enz.)
import { createClient } from '@supabase/supabase-js';

const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY! // NOOIT aan derden tonen
);

// Dit omzeilt RLS - gebruik met grote voorzichtigheid
const { data } = await supabaseAdmin
  .from('tenants')
  .select('id, name, plan')
  .eq('plan', 'enterprise');

Als je op Next.js bouwt (we doen veel van dit soort werk), zijn je API-routes en Server Components de juiste plaats voor service role-bewerkingen.

Databasefuncties voor gecontroleerde cross-tenant-toegang

Voor meer fijnmazig beheer, maak specifieke functies:

create or replace function public.admin_get_tenant_stats(target_tenant_id uuid)
returns json
language plpgsql
security definer
set search_path = public
as $$
declare
  result json;
  caller_role text;
begin
  -- Verifieer dat de aanroeper een admin is van de doeltenant
  select role into caller_role
  from tenant_memberships
  where user_id = auth.uid() and tenant_id = target_tenant_id;
  
  if caller_role not in ('owner', 'admin') then
    raise exception 'Niet gemachtigd: admin-rol vereist';
  end if;
  
  select json_build_object(
    'project_count', (select count(*) from projects where tenant_id = target_tenant_id),
    'task_count', (select count(*) from tasks where tenant_id = target_tenant_id),
    'member_count', (select count(*) from tenant_memberships where tenant_id = target_tenant_id)
  ) into result;
  
  return result;
end;
$$;

Migratiestrategieën en schema-evolutie

tenant_id toevoegen aan bestaande tabellen is de migratie waar iedereen tegen opsiet. Hier is de aanpak die downtime minimaliseert:

-- Stap 1: Voeg nullable kolom toe
alter table public.some_existing_table 
  add column tenant_id uuid references public.tenants(id);

-- Stap 2: Teruginvullen (doe dit in batches voor grote tabellen)
update public.some_existing_table set tenant_id = (
  select tenant_id from public.projects 
  where projects.id = some_existing_table.project_id
)
where tenant_id is null;

-- Stap 3: Voeg NOT NULL-beperking toe
alter table public.some_existing_table 
  alter column tenant_id set not null;

-- Stap 4: Voeg index toe
create index concurrently idx_some_table_tenant 
  on public.some_existing_table(tenant_id);

-- Stap 5: Zet RLS aan en voeg beleid toe
alter table public.some_existing_table enable row level security;

create policy "tenant_isolation" on public.some_existing_table
  for all using (tenant_id in (select public.get_user_tenant_ids()));

Het concurrently-sleutelwoord op de index-creatie is cruciaal -- zonder het vergrendel je de tabel voor de gehele index-opbouw. Op een tabel met een miljoen rijen kan dat minuten downtime zijn.

Prestatiewaarden en optimalisatie

Ik voerde benchmarks uit op een Supabase Pro-plan (€22,50/maand, begin 2025) met 500.000 rijen in de tasks-tafel verdeeld over 1.000 tenants.

Querypatroon Zonder RLS Naïef RLS Geoptimaliseerd RLS Overhead
50 rijen selecteren (enkele tenant) 2,1ms 18,4ms 3,8ms +81%
Enkele rij invoegen 1,2ms 4,1ms 1,9ms +58%
Tellen met filter 3,4ms 22,1ms 5,2ms +53%
JOIN over 2 tabellen 4,8ms 45,2ms 8,1ms +69%

"Geoptimaliseerd RLS" betekent: security definer-functies, goede samengestelde indexen, gedenormaliseerde tenant_id op kindtabellen, en STABLE-functie-volatiliteit.

De naïeve aanpak (inline-subqueries, ontbrekende indexen) maakt RLS voelen langzaam. Geoptimaliseerd is de overhead volkomen aanvaardbaar voor productiewerklasten.

Aanvullende optimalisatietips

  1. Gebruik samengestelde indexen met tenant_id als de eerste kolom: create index on tasks(tenant_id, status, created_at)
  2. Partitioneer grote tabellen door tenant_id als een enkele tenant miljoenen rijen heeft
  3. Gebruik pg_stat_statements om trage queries te vinden -- Supabase stelt dit bloot in het dashboard onder Database > Query Performance
  4. Overweeg gerealiseerde weergaven voor cross-tenant-analyse in plaats van dure aggregaties in real-time uit te voeren

Beveiligingshardening voor productie

RLS is je primaire verdediging, maar het mag je enige niet zijn.

1. Standaard Weigeren

Zet altijd RLS aan zonder standaardbeleid -- dit betekent dat als je vergeet beleid toe te voegen aan een nieuwe tafel, het vergrendeld is in plaats van wijd open.

alter table public.new_table enable row level security;
-- Voeg geen permissief beleid toe totdat je de toegangspatronen hebt doordacht

2. Auditlogboek

create table public.audit_log (
  id bigint generated always as identity primary key,
  tenant_id uuid not null,
  user_id uuid,
  action text not null,
  table_name text not null,
  record_id uuid,
  old_data jsonb,
  new_data jsonb,
  created_at timestamptz not null default now()
);

-- Generieke audit-trigger
create or replace function public.audit_trigger_func()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
  if TG_OP = 'DELETE' then
    insert into audit_log(tenant_id, user_id, action, table_name, record_id, old_data)
    values (OLD.tenant_id, auth.uid(), TG_OP, TG_TABLE_NAME, OLD.id, to_jsonb(OLD));
    return OLD;
  else
    insert into audit_log(tenant_id, user_id, action, table_name, record_id, new_data, old_data)
    values (
      NEW.tenant_id, auth.uid(), TG_OP, TG_TABLE_NAME, NEW.id, 
      to_jsonb(NEW),
      case when TG_OP = 'UPDATE' then to_jsonb(OLD) else null end
    );
    return NEW;
  end if;
end;
$$;

3. Tarieflimitering bij de Edge

RLS voorkomt dataverlies, maar voorkomt geen misbruik. Gebruik Supabase Edge Functions of je Next.js-middleware voor tarieflimitering. Als je met Astro bouwt, kun je dit in je server-endpoints afhandelen.

4. Test je beleid

Schrijf werkelijke testen. Gebruik Supabase's lokale ontwikkelomgeving (supabase start) en test dat:

  • Gebruiker A kan gegevens van tenant B niet zien
  • Een lidmaatschap verwijderen trekt onmiddellijk toegang in
  • Service role omzeilt RLS correct
  • Insert/update-beleid voorkomt tenant_id-vervalsen
// Voorbeeld test met Vitest
import { describe, it, expect } from 'vitest';
import { createClient } from '@supabase/supabase-js';

describe('RLS-beleid', () => {
  it('zou cross-tenant gegevenstoegang moeten voorkomen', async () => {
    const userA = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
      global: { headers: { Authorization: `Bearer ${tokenA}` } }
    });
    
    const { data, error } = await userA
      .from('projects')
      .select('*')
      .eq('tenant_id', TENANT_B_ID);
    
    expect(data).toHaveLength(0); // RLS zou deze moeten filteren
  });
});

Realistisch schemavoorbeeld

Hier is een versmald maar volledig schema voor een projectmanagement-SaaS. Dit ligt dicht bij wat ik in productie heb ingezet.

-- Zet RLS aan op alle tabellen
alter table public.tenants enable row level security;
alter table public.tenant_memberships enable row level security;
alter table public.projects enable row level security;
alter table public.tasks enable row level security;

-- Tenants: gebruikers kunnen tenants zien waarvan zij lid zijn
create policy "view_own_tenants" on public.tenants for select
  using (id in (select public.get_user_tenant_ids()));

-- Alleen eigenaars kunnen tenant-instellingen bijwerken
create policy "owners_update_tenants" on public.tenants for update
  using (
    id in (
      select tenant_id from public.tenant_memberships
      where user_id = auth.uid() and role = 'owner'
    )
  );

-- Lidmaatschappen: leden kunnen andere leden in hun tenants zien
create policy "view_tenant_members" on public.tenant_memberships for select
  using (tenant_id in (select public.get_user_tenant_ids()));

-- Alleen admins+ kunnen lidmaatschappen beheren
create policy "admins_manage_members" on public.tenant_memberships for insert
  with check (
    tenant_id in (
      select tenant_id from public.tenant_memberships
      where user_id = auth.uid() and role in ('owner', 'admin')
    )
  );

-- Projecten & Taken: standaard tenant-isolatie
create policy "tenant_projects" on public.projects for all
  using (tenant_id in (select public.get_user_tenant_ids()))
  with check (tenant_id in (select public.get_user_tenant_ids()));

create policy "tenant_tasks" on public.tasks for all
  using (tenant_id in (select public.get_user_tenant_ids()))
  with check (tenant_id in (select public.get_user_tenant_ids()));

Veelgestelde vragen

Voegt Supabase RLS aanzienlijke latentie toe aan queries? Met geoptimaliseerd beleid (security definer-functies, goede indexen, gedenormaliseerde tenant_id), is de overhead doorgaans 50-80% bovenop de ruwe querytijd. Voor een query die 3ms duurt zonder RLS, verwacht je 5-6ms ermee. Dit is volkomen aanvaardbaar voor productiegebruik. De naïeve aanpak kan 10-20x overhead toevoegen, daarom is optimalisatie zo belangrijk.

Kan ik RLS gebruiken met Supabase Realtime-abonnementen? Ja. Supabase Realtime respecteert RLS-beleid. Wanneer een client zich abonneert op wijzigingen in een tafel, ontvangen zij alleen events voor rijen die zij mogen zien. Dit werkt automatisch -- je hoeft geen extra filterlogica op de client toe te voegen. Zorg ervoor dat je RLS-beleid efficiënt is, want het wordt voor elke broadcast geëvalueerd.

Hoe zet ik tenant-omschakeling in de UI om? Sla de actieve tenant-ID op in je app-status (React context, Zustand store, enz.) en geef het door als filter in je queries. Omdat RLS al resultaten tot tenants waarvan de gebruiker lid is beperkt, omschakelen is gewoon het veranderen van welke tenant_id je filtert. Geen token-vernieuwing nodig als je de databasezoek-aanpak gebruikt.

Moet ik Supabase's ingebouwde auth gebruiken of een externe provider als Clerk? Supabase Auth integreert natuurlijk met RLS door auth.uid(). Als je een externe provider gebruikt, moet je gebruikers in Supabase synchroniseren en aangepaste JWT-claims of een toewijzingstafel gebruiken. Ik zou bij Supabase Auth blijven tenzij je een specifieke reden hebt om niet te -- het bespaart aanzienlijke integratiewerk.

Hoeveel tenants kan een enkel Supabase-project verwerken? Met gedeelde schema multi-tenancy heb ik persoonlijk 2.000+ tenants op een Supabase Pro-plan (€22,50/maand) gerund. De praktische limiet hangt af van totaal gegevensvolume en querypatronen, niet tenantaantal. Een Supabase Pro-exemplaar met 2 vCPU's en 1GB RAM kan tabellen met tientallen miljoenen rijen verwerken als je indexen goed zijn.

Wat gebeurt er als ik vergeet RLS op een nieuwe tafel in te schakelen? Als je de Supabase-client met de anon-sleutel gebruikt, is elke tafel zonder RLS ingeschakeld toegankelijk voor iedereen met die sleutel. Dit is de grootste valkuil in Supabase. Zet een CI-controle op die verifieert dat alle tabellen in het public-schema RLS ingeschakeld hebben. Je kunt pg_tables gekoppeld met pg_class opvragen om deze controle te automatiseren.

Kan ik RLS gebruiken met Supabase Edge Functions? Ja. Edge Functions kunnen een Supabase-client maken met ofwel de anon-sleutel (RLS van toepassing) of de service role-sleutel (RLS omzeild). Gebruik de anon-sleutel met de JWT van de gebruiker voor gebruikersgerichte bewerkingen, en de service role-sleutel voor administratieve taken die cross-tenant-toegang nodig hebben.

Hoe migreer ik van een single-tenant-app naar multi-tenant? Voeg tenant_id als een nullable kolom toe, vul het voor alle bestaande gegevens in (alle rijen krijgen dezelfde tenant-ID omdat het single-tenant was), voeg dan de NOT NULL-beperking, indexen en RLS-beleid toe. Test uitgebreid met je bestaande gegevens voordat je RLS inschakelt -- één verkeerd beleid kan alle je gebruikers uitsluiten. Gebruik Supabase's lokale ontwikkelomgeving om de migratie te oefenen.