Waarom Supabase RLS voor Multi-Tenancy

Je SaaS-app gaat live met drie tenants. Supabase RLS-policies zien er strak uit—team_id checks op elke tabel, service role op slot, middleware stuurt de juiste JWT claims door. Dan onboards tenant vier, en een developer ziet het dashboard van een ander bedrijf elf seconden lang voordat je Slack ontploft. Aparte databases per tenant hadden dit voorkomen, zeker—maar tegen $47/maand per Postgres instance en nul mogelijkheden voor cross-tenant analytics, die rekening gaat niet op. Gedeelde schemas voelen verstandiger totdat je eerste migratie 8.000 rijen over twaalf tenants vergrendelt en support tickets zich opstapelen. De architectuur die productie echt overleeft: gedeelde tabellen met Supabase Row Level Security. PostgreSQL-native, subMilliseconde policy checks, en je schrijft de isolatielogica eenmaal op. Maar RLS in productie heeft zeven scherpe randen die we in staging niet zagen—hier staat elke één, plus de patterns die ze repareren.

Waarom maakt dat überhaupt uit? Simpel. Je data filtering gebeurt op database niveau. Als je een WHERE clause in je Next.js API route verknalt, zit je niet midden in de nacht te piekeren over datalekken, omdat de database zelf je veiligheidsnet is. En eigenlijk, tegenwoordig, is dat geen luxe—het's een noodzaak.

Maar laten we niet doen alsof RLS zonder prijs gaat. Het voegt overhead toe aan je queries, maakt debugging lastiger, en kan je struikelen tijdens migraties. Dus, hoe stapelen verschillende multi-tenancy benaderingen op?

Benadering Isolatieniveau Kosten Operationele Complexiteit Query Performance
Database per tenant Compleet Hoog ($50-200/tenant/mnd) Erg Hoog Best
Schema per tenant Sterk Middel Hoog (migraties) Goed
Gedeelde tabellen + RLS Rij-niveau Laag Middel Goed (met voorbehoud)
Application-level filtering Geen Laagst Laag Best

Voor de meeste SaaS producten met minder dan 10.000 tenants geeft gedeelde tabellen met RLS je de beste waarde voor je geld. Dat is wat we hier duiken.

Multi-Tenant Next.js with Supabase RLS: Production Guide

Architecture Patterns: Gedeeld vs Geïsoleerd

Voordat je zelfs maar aan code begint, moet je je tenant resolution strategie kiezen. In het wild loop je meestal tegen twee beesten aan:

Subdomain-Gebaseerde Tenancy

Ooit tenant-slug.yourapp.com gezien? Welkom bij het meest voorkomende patroon voor B2B SaaS. Het's strak, professioneel, en maakt tenant resolution in middleware een fluitje van een cent.

Pad-Gebaseerde Tenancy

Dit is je standaard /org/tenant-slug/dashboard. Makkelijker in te stellen zonder wildcard DNS, en het werkt op platforms als Vercel zonder custom domeinen. Maar eerlijk gezegd: het voelt een beetje als sokken met sandalen dragen. We adviseren normaal subdomain-gebaseerd voor productie B2B apps en pad-gebaseerd voor interne tools of MVP's. Later switchen? Dan vloek je je vorige zelf—deze patterns veranderen is geen grap.

Het Tenant Schema Instellen

Hier is een schema pattern dat ons in drie verschillende productie rollouts niet in de steek heeft gelaten:

-- Core tenant table
CREATE TABLE organizations (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  slug TEXT UNIQUE NOT NULL,
  plan TEXT NOT NULL DEFAULT 'free',
  created_at TIMESTAMPTZ DEFAULT now(),
  settings JSONB DEFAULT '{}'
);

-- Membership junction table
CREATE TABLE memberships (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
  org_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
  role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
  created_at TIMESTAMPTZ DEFAULT now(),
  UNIQUE(user_id, org_id)
);

-- Example tenant-scoped table
CREATE TABLE projects (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  org_id UUID REFERENCES organizations(id) ON DELETE CASCADE NOT NULL,
  name TEXT NOT NULL,
  description TEXT,
  created_by UUID REFERENCES auth.users(id),
  created_at TIMESTAMPTZ DEFAULT now()
);

-- Index on org_id — je hebt dit nodig op ELKE tenant-scoped table
CREATE INDEX idx_projects_org_id ON projects(org_id);
CREATE INDEX idx_memberships_user_id ON memberships(user_id);
CREATE INDEX idx_memberships_org_id ON memberships(org_id);

De memberships tabel is de lijm die alles samenbindt. Al je RLS policies wijzen ernaar alsof het hun favoriete neef is. Gebruikers kunnen meerdere organisaties bijtreden, en hun rollen bepalen wat ze wel of niet kunnen doen. En hier is een stukje wijsheid: altijd—echt, altijd—index org_id op elke tenant-scoped table. Anders watch je queries traag als melasse eenmaal je in data zwemt. We werden verrast toen de dashboard van een klant van 50ms naar 8 seconden duikelde met 100.000 rijen. Les geleerd.

RLS Policies Die Echt Schalen

Hier buigen tutorials typisch af, je helemaal stranded latend. Ze gooien auth.uid() = user_id naar je en zeggen, "Veel sterkte!" Maar multi-tenant RLS kan niet zo worden vereenvoudigd.

Het Helper Function Pattern

Waarom elke policy met membership checks bezatten? Gebruik in plaats daarvan een helper function:

-- Helper: check of huidige user een member is van een org
CREATE OR REPLACE FUNCTION public.is_member_of(org UUID)
RETURNS BOOLEAN AS $$
  SELECT EXISTS (
    SELECT 1 FROM memberships
    WHERE user_id = auth.uid()
    AND org_id = org
  );
$$ LANGUAGE sql SECURITY DEFINER STABLE;

-- Helper: krijg user's role in een org
CREATE OR REPLACE FUNCTION public.get_role_in(org UUID)
RETURNS TEXT AS $$
  SELECT role FROM memberships
    WHERE user_id = auth.uid()
    AND org_id = org
  LIMIT 1;
$$ LANGUAGE sql SECURITY DEFINER STABLE;

Waarom SECURITY DEFINER? Omdat de function runt met de maker's privileges, RLS op de memberships tabel springend over. Zonder dit risiceer je in een circular dependency konijnenhol te vallen waar RLS op memberships het membership checks waar andere tabellen op vertrouwen crasht.

En het STABLE deel? Het signaleert aan de query planner dat de function's output consistent blijft voor dezelfde input tijdens een enkele query, wat wat fijne cache benefits mogelijk maakt. In verleiding om IMMUTABLE te gebruiken? Niet doen. Membership kan tussen transacties flippen.

Policies voor Tenant-Scoped Tables

Laten we wat policies voor onze projects tabel bekijken:

-- Enable RLS
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

-- SELECT: members kunnen projects in hun orgs bekijken
CREATE POLICY "Members can view org projects"
  ON projects FOR SELECT
  USING (public.is_member_of(org_id));

-- INSERT: admins en owners kunnen projects maken
CREATE POLICY "Admins can create projects"
  ON projects FOR INSERT
  WITH CHECK (
    public.get_role_in(org_id) IN ('owner', 'admin')
  );

-- UPDATE: admins en owners kunnen projects updaten
CREATE POLICY "Admins can update projects"
  ON projects FOR UPDATE
  USING (public.is_member_of(org_id))
  WITH CHECK (
    public.get_role_in(org_id) IN ('owner', 'admin')
  );

-- DELETE: alleen owners kunnen projects verwijderen
CREATE POLICY "Owners can delete projects"
  ON projects FOR DELETE
  USING (
    public.get_role_in(org_id) = 'owner'
  );

Policies voor de Memberships Tabel Zelf

Deze is lastig. De memberships tabel krijgt zijn eigen RLS, maar kan de helper functions niet gebruiken omdat die op hun beurt memberships query—cue circular reference nachtmerries:

ALTER TABLE memberships ENABLE ROW LEVEL SECURITY;

-- Users kunnen memberships zien in orgs waar ze bij horen
CREATE POLICY "Users can view org memberships"
  ON memberships FOR SELECT
  USING (
    org_id IN (
      SELECT org_id FROM memberships WHERE user_id = auth.uid()
    )
  );

-- Alleen owners kunnen members toevoegen
CREATE POLICY "Owners can add members"
  ON memberships FOR INSERT
  WITH CHECK (
    org_id IN (
      SELECT org_id FROM memberships
      WHERE user_id = auth.uid() AND role = 'owner'
    )
  );

Ja, er is een subquery op dezelfde tabel. En ja, PostgreSQL naakt het. De subquery checkt je eigen membership, onbeïnvloed door de policy die wordt gedefinieerd omdat RLS alleen rond de buitenste query wikkelt. Maar test dit—serieus, je wilt zo'n bug niet in productie ontdekken.

Multi-Tenant Next.js with Supabase RLS: Production Guide - architecture

Next.js Middleware voor Tenant Resolution

Met Next.js 15 en de snelle App Router, middleware op de edge is de perfecte huisbaas voor tenant resolution. Hier is ons betrouwbare pattern voor subdomain-gebaseerde setups:

// middleware.ts
import { createServerClient } from '@supabase/ssr';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

const PUBLIC_ROUTES = ['/login', '/signup', '/invite'];

export async function middleware(request: NextRequest) {
  const hostname = request.headers.get('host') || '';
  const currentHost = hostname.split('.')[0];

  // Skip voor main domain en localhost
  const isMainDomain = currentHost === 'app' || currentHost === 'www' || currentHost === 'localhost:3000';

  let response = NextResponse.next({
    request: { headers: request.headers },
  });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll();
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value, options }) => {
            request.cookies.set(name, value);
            response.cookies.set(name, value, options);
          });
        },
      },
    }
  );

  const { data: { user } } = await supabase.auth.getUser();

  if (!isMainDomain) {
    response.headers.set('x-tenant-slug', currentHost);

    if (!user && !PUBLIC_ROUTES.some(r => request.nextUrl.pathname.startsWith(r))) {
      return NextResponse.redirect(new URL('/login', request.url));
    }
  }

  return response;
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|api/webhooks).*)'],
};

De x-tenant-slug header is puur goud. Gebruik het om je Server Components en API routes te laten weten met welke tenant ze te maken hebben. Als je met ons samenwerkt aan een Next.js project, is dit instellen onze dag één prioriteit.

Authentication Flow in Multi-Tenant Apps

Supabase Auth speelt het neutraal in het multi-tenancy spel. Gebruikers bestaan in een globale sfeer—tenant relaties zijn jouw puzzel om op te lossen. Hier is ons gameplan:

  1. User registreert: Maak een auth user, bouw een organisatie, en tovering een membership op met een 'owner' role.
  2. User wordt uitgenodigd: De admin trekt een pending invite op, een nieuwe user joinet via de invite link, en poef—een membership verschijnt met de opgegeven role.
  3. User logt in: Extract tenant uit subdomain, bevestig membership, escorteer ze naar hun dashboard.
// app/api/auth/signup/route.ts
import { createClient } from '@/lib/supabase/server';
import { NextResponse } from 'next/server';

export async function POST(request: Request) {
  const { email, password, orgName, orgSlug } = await request.json();
  const supabase = await createClient();

  // Sign up the user
  const { data: authData, error: authError } = await supabase.auth.signUp({
    email,
    password,
  });

  if (authError) return NextResponse.json({ error: authError.message }, { status: 400 });

  // Use a service role client for org creation (bypasses RLS)
  const adminClient = createAdminClient();

  const { data: org, error: orgError } = await adminClient
    .from('organizations')
    .insert({ name: orgName, slug: orgSlug })
    .select()
    .single();

  if (orgError) return NextResponse.json({ error: orgError.message }, { status: 400 });

  // Create ownership membership
  await adminClient
    .from('memberships')
    .insert({
      user_id: authData.user!.id,
      org_id: org.id,
      role: 'owner',
    });

  return NextResponse.json({ org });
}

Merk op dat we een service role client gebruiken tijdens signup. De user heeft nog geen memberships, dus RLS zou ze afhaken voor organisation creation. Het's één van die klassieke bootstrapping issues—je service role key zal je toverstaf zijn.

En ik kan dit niet genoeg benadrukken: Never, ever expose je service role key naar de client. Het's strikt voor server-side code.

Server Components en RLS: Het SSR Probleem

Next.js 15's Server Components zijn server-gebonden, wat het beveiligingsspel oploopt. Maar er's een hik als je Supabase RLS gebruikt: je moet de user's session leveren aan de Supabase client zodat RLS policies weten wie aan de tafel zit.

// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

export async function createClient() {
  const cookieStore = await cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          } catch {
            // Dit kan falen in Server Components (read-only)
            // De middleware handelt cookie refreshing af
          }
        },
      },
    }
  );
}
// app/[orgSlug]/projects/page.tsx
import { createClient } from '@/lib/supabase/server';
import { headers } from 'next/headers';

export default async function ProjectsPage() {
  const supabase = await createClient();
  const headersList = await headers();
  const tenantSlug = headersList.get('x-tenant-slug');

  // Get the org ID from slug
  const { data: org } = await supabase
    .from('organizations')
    .select('id')
    .eq('slug', tenantSlug)
    .single();

  if (!org) return <div>Organization not found</div>;

  // RLS automatically filters — only returns projects
  // where the current user has membership
  const { data: projects } = await supabase
    .from('projects')
    .select('*')
    .eq('org_id', org.id)
    .order('created_at', { ascending: false });

  return (
    <div>
      {projects?.map(project => (
        <ProjectCard key={project.id} project={project} />
      ))}
    </div>
  );
}

Hier is het kneepje: zelfs als iemand de org_id in het request foeters, buigt RLS niet. Het blokkeert access tot projects tenzij de user een member is. Technisch gezien is .eq('org_id', org.id) overbodig voor veiligheid—RLS handelt dat af—maar het's goed voor performance en leesbaarheid.

Performance Optimalisatie en Veelvoorkomende Valkuilen

Het N+1 RLS Query Probleem

Elke RLS policy check draait een subquery op. Hook in een 10-rij policy check als je 100 rijen oogst betekent 100 rondes membership lookup. Gelukkig is PostgreSQL slim genoeg om te cachet—maar er's overhead.

Fix: Gebruik STABLE op helper functions (zoals we hebben uiteengezet). Ook, denk na over denormalisering van org_id in de JWT claims:

-- Custom JWT hook (Supabase Dashboard > Auth > Hooks)
CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event JSONB)
RETURNS JSONB AS $$
DECLARE
  org_ids UUID[];
BEGIN
  SELECT array_agg(org_id) INTO org_ids
  FROM memberships
  WHERE user_id = (event->>'user_id')::UUID;

  event := jsonb_set(
    event,
    '{claims,org_ids}',
    to_jsonb(org_ids)
  );

  RETURN event;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

Dan wordt je RLS policy:

CREATE POLICY "Members can view"
  ON projects FOR SELECT
  USING (
    org_id = ANY(
      (SELECT array(SELECT jsonb_array_elements_text(
        auth.jwt()->'org_ids'
      ))::UUID[])
    )
  );

Dit smaalt de membership table lookup helemaal weg. De org IDs komen recht uit de JWT. Voorbehoud: JWT claims zijn gestempeld bij login. Verander iemand's membership, en ze zullen opnieuw moeten verifieëren om claims te synchen. Typisch is dat totaal beheerbaar—houd het gewoon in je docs.

Connection Pooling

Supabase serveert connection pooling via PgBouncer. Als je live gaat met Next.js op Vercel, onthoud: pooler URL voor API routes en server components.

# Voor normale operaties (pooled)
DATABASE_URL=postgres://user:pass@db.project.supabase.co:6543/postgres

# Voor migraties alleen (direct)
DIRECT_URL=postgres://user:pass@db.project.supabase.co:5432/postgres

Iedereen op Supabase's Pro voor $25 per maand krijgt 200 concurrent connections via de pooler. Voor de meeste SaaS apps verlegen van 1000 users concurrent is het meer dan genoeg.

Indexes Die Je Echt Nodig Hebt

Hier is de brute-force index set voor een multi-tenant setup:

-- Op elke tenant-scoped table
CREATE INDEX idx_{table}_org_id ON {table}(org_id);

-- Composite indexes voor veelvoorkomende queries
CREATE INDEX idx_projects_org_created ON projects(org_id, created_at DESC);

-- Memberships — veel gequery'd door RLS
CREATE INDEX idx_memberships_user_org ON memberships(user_id, org_id);
CREATE INDEX idx_memberships_org_role ON memberships(org_id, role);

EXPLAIN ANALYZE—een developer's beste vriend. Zie hoe je queries presteren met RLS aan boord. Je krijgt misschien een schrik op wat de planner beslist zonder de juiste indexes.

RLS Policies Testen

Iedereen slaat dit over, maar het's je best veiligheidsnet tegen datalekken. We testen RLS policies recht in SQL:

-- Test als een specifieke user
SET request.jwt.claims = '{"sub": "user-uuid-here", "role": "authenticated"}';
SET role = 'authenticated';

-- Dit zou alleen projects moeten retourneren waar de user toegang tot heeft
SELECT * FROM projects;

-- Dit zou falen (user is geen member van deze org)
INSERT INTO projects (org_id, name) VALUES ('other-org-uuid', 'Sneaky Project');

-- Reset
RESET role;

En vergeet niet pgTAP voor kritieke policies:

BEGIN;
SELECT plan(3);

-- Set up test context als user A (member van org 1)
SET LOCAL request.jwt.claims = '{"sub": "user-a-uuid"}';
SET LOCAL role = 'authenticated';

SELECT is(
  (SELECT count(*) FROM projects WHERE org_id = 'org-1-uuid')::INTEGER,
  5,
  'User A ziet 5 projects in hun org'
);

SELECT is(
  (SELECT count(*) FROM projects WHERE org_id = 'org-2-uuid')::INTEGER,
  0,
  'User A ziet 0 projects in ander org'
);

SELECT throws_ok(
  $$INSERT INTO projects (org_id, name) VALUES ('org-2-uuid', 'Hack')$$,
  'new row violates row-level security policy',
  'User A kan niet invoegen in ander org'
);

SELECT * FROM finish();
ROLLBACK;

Run deze in CI. Elke migratie die met RLS policies speelt zou de volledige test suite door een grondige workout sturen.

Production Deployment Checklist

Klaar om uit te sturen? Wapen jezelf met dit:

  • RLS ingeschakeld op elke tabel met tenant data
  • Service role key opgesloten server-side, nergens in de buurt van een client
  • org_id correct geïndexeerd op alle tenant-scoped tabellen
  • Membership helper functions benoemd als SECURITY DEFINER en STABLE
  • JWT custom claims geladen (als je de JWT route doet)
  • Connection pooling in gereedheid voor cloud deployment?
  • RLS policies vers uit QA testen met pgTAP of gelijken
  • EXPLAIN ANALYZE opgedraaid op cruciale queries met RLS rennend
  • Invite/signup flow mist geen membership bootstraps
  • Rate limiting op auth endpoints? Supabase biedt ingebouwde opties
  • Flip de switch op RLS voor auth schema tabellen in Supabase Dashboard (vaak een mijnenveld)
  • Geëmbedde monitoring voor trage queries (Supabase Dashboard > Database > Query Performance)

Rolling een multi-tenant product uit en wil iemand die deze wateren al doorgeploed heeft? Onze headless CMS development solutions of een snelle chat via onze contact page zou net het zijn wat je nodig hebt.

Veelgestelde Vragen

Kan ik Supabase RLS gebruiken voor apps met duizenden tenants? Absoluut. We hebben shared-table RLS met 5.000+ tenants en miljoenen rijen zonder een zweet te hebben geprobeerd. Het geheim? Juiste indexering op org_id kolommen en STABLE helper functions. 50.000+ tenants overwegen of miljardrijl marathons? Duik in partitionering van tabellen naar org_id of flirt met een schema-per-tenant setup.

Hoe handle ik tenant switching als een user bij meerdere organisaties hoort? Houd de actieve organisatie weggestopt in een cookie of URL (subdomain). Swap orgs? Pas de subdomain/cookie aan en fetch opnieuw. Tuck niet de actieve org in de JWT—het vraagt om een relog om te veranderen. Een cookie je middleware kan inspecteren is the way to go.

Wat gebeurt er als ik vergeet RLS op een tabel in te schakelen? Elke geverifieerde user kon elke rij afkloppen. Dat's PostgreSQL's standaard houding—geen rij-level beperkingen op tabellen zonder RLS. Supabase Dashboard vlaggen tabellen zonder RLS, maar het inbedden van dit in CI met queries naar pg_tables en pg_policies helpt ook.

Zou ik Supabase's service role key gebruiken of een aangepaste PostgreSQL role bakken voor admin duties? Moet zeggen dat de service role key meestal volstaat. Het ontvangt RLS volledig, dus het's je top geheim voor server-side use alleen. Behoefte aan granulaire governance (zoals een "admin" role lurking in alle orgs maar verlegen van deletions)? Dat's aangepaste PostgreSQL terrein—geavanceerd en over het algemeen af je radar tot complexe interne tooling het vraagt.

Hoe loop ik database migraties zonder over RLS policies te struikelen? Supabase's CLI (supabase db push of supabase migration) samen met de direct database URL (skipping pooled) heeft je rug. Tuck RLS policy edits in dezelfde migratie als schema tweaks. Test cast migraties tegenaan een staging project—Supabase laat je preview branches op Pro juist voor dit soort ding opdraaien.

Kunnen RLS policies extern naar data van andere APIs of services reiken? Nee. RLS policies zitten netjes in SQL, geëvalueerd door PostgreSQL. Fancy checking op externe data (zoals een feature flag service)? Cement die data in een database tabel, dan reference in je policy. Een typisch pattern is synching subscription statuses van Stripe naar een organizations.plan kolom.

Wat's de performance belasting van RLS vergeleken met filtering op application layer? Over in onze Supabase Pro benchmarks (2 vCPU's, 8GB RAM), smert RLS een extra 1-3ms per query voor basis membership-check policies met de juiste indexes. Ga wild met policy complexiteit of joins en je zou 5-15ms kunnen toevoegen. De JWT claims tactic (storing org_ids in het token) slaagt het onder 1ms omdat er geen subquery dans is. Voor typische web apps, die lepel van latency is verwaarloosbaar.

Hoe werkt dit met Supabase Realtime subscriptions? Supabase Realtime speelt naar de RLS regel boek. Tune in op table changes en vang alleen events van rijen je berechtigd bent te zien volgens RLS. Dit rolt out-of-the-box met nul extra tinkering. Gewoon zeker je client-side Supabase had de user session, die @supabase/ssr naadloos handelt.