Wie du in Supabase mit Row Level Security mehrere Mandanten sauber trennst: Policy-Aufbau, tenant_id-Strategie, typische Sicherheitslecks und wie du die Trennung wirklich testest.
Was Multi-Tenant in einer geteilten Datenbank bedeutet
In einem Multi-Tenant-SaaS teilen sich mehrere Kunden, die Mandanten, dieselbe Datenbank und oft dieselben Tabellen. Die Zeile eines Kunden darf für keinen anderen sichtbar werden. In Supabase, das auf Postgres läuft, ist das Werkzeug dafür Row Level Security. RLS erlaubt dir, pro Tabelle Regeln zu definieren, die festlegen, welche Zeilen ein Nutzer sehen und verändern darf.
Der entscheidende Punkt: RLS ist standardmäßig aus. Solange du es nicht explizit per ALTER TABLE ... ENABLE ROW LEVEL SECURITY einschaltest, sieht jeder authentifizierte Nutzer alles. Eine vergessene Tabelle ist kein Schönheitsfehler, sondern ein offenes Datenleck zwischen Kunden. Mandantentrennung ist deshalb keine Funktion, die du einmal baust, sondern eine Eigenschaft, die auf jeder einzelnen Tabelle gelten muss.
tenant_id als roter Faden
Die gängige Struktur ist eine Spalte tenant_id auf jeder mandantenbezogenen Tabelle. Daneben gibt es eine Zuordnung, welcher Nutzer zu welchem Mandanten gehört, etwa eine Tabelle memberships mit user_id und tenant_id. Über diese Brücke entscheidet die Policy, ob ein Nutzer eine Zeile sehen darf.
Die Alternative ist, die tenant_id direkt in die JWT-Claims des Nutzers zu schreiben. Dann liest die Policy sie ohne zusätzlichen Tabellen-Zugriff aus dem Token. Das ist schneller, verlagert aber die Verantwortung in den Ausstellungsprozess des Tokens. Wechselt ein Nutzer den Mandanten, muss das Token neu ausgestellt werden, sonst greift er mit altem Claim auf den falschen Mandanten zu. Beide Wege funktionieren, sie haben nur unterschiedliche Fehlerquellen.
Eine Policy von innen
Eine Policy ist ein boolescher Ausdruck, den Postgres pro Zeile auswertet. Für Lesezugriff schreibst du eine USING-Bedingung, für Schreibzugriff zusätzlich eine WITH CHECK-Bedingung. auth.uid() liefert die ID des aktuell angemeldeten Nutzers aus dem JWT. Eine typische Lese-Policy prüft, ob es eine Mitgliedschaft gibt, die den Nutzer mit dem Mandanten der Zeile verbindet.
Der häufigste Fehler ist, nur USING zu setzen und WITH CHECK zu vergessen. Dann kann ein Nutzer zwar nur eigene Zeilen lesen, aber beim Einfügen oder Aktualisieren eine fremde tenant_id setzen und so Daten in einen anderen Mandanten schreiben. Lese- und Schreibregel müssen beide die Mandantenzugehörigkeit erzwingen, sonst ist die Trennung halbseitig.
Die Lecks, die niemand sofort sieht
Drei Lecks tauchen immer wieder auf. Erstens die Tabelle ohne aktiviertes RLS, oft eine spät hinzugefügte Hilfstabelle. Zweitens der Service-Role-Key, der RLS bewusst umgeht und versehentlich clientseitig landet, womit jede Policy wirkungslos wird. Drittens Postgres-Funktionen mit SECURITY DEFINER, die mit den Rechten des Erstellers laufen und so an RLS vorbei lesen, wenn sie nicht sorgfältig eingegrenzt sind.
Dazu kommt die fehlende WITH CHECK-Klausel aus dem letzten Abschnitt und Views, die ohne security_invoker die Rechte ihres Besitzers erben statt die des Aufrufers. Keiner dieser Fehler wirft eine Fehlermeldung. Die Anwendung funktioniert, die Daten fließen, und erst ein neugieriger oder böswilliger Nutzer findet die Lücke. Deshalb prüfen wir diese Punkte bei jedem Supabase-Setup einzeln.
Testen, als wärst du der fremde Mandant
Mandantentrennung testest du nicht durch Anschauen, sondern durch Angreifen. Du legst mindestens zwei Mandanten mit je eigenen Nutzern an und schreibst Tests, die als Nutzer A versuchen, Zeilen von Mandant B zu lesen, zu ändern und zu löschen. Jeder dieser Versuche muss leer zurückkommen oder fehlschlagen. Ein Test, der nur den Erfolgsfall prüft, übersieht genau die Lecks, die wehtun.
Praktisch geht das, indem du in der Testumgebung echte JWTs für verschiedene Nutzer ausstellst und die Abfragen über den anon-Client laufen lässt, nicht über den Service-Role-Key. Nur so durchlaufen die Abfragen RLS wie in Produktion. Ergänzend prüfst du mit einer Abfrage über den Systemkatalog, ob wirklich auf jeder Tabelle RLS aktiv ist. Diese Tests gehören in die CI, damit eine neue Tabelle ohne Policy den Build bricht statt still Daten zu lecken. Wie wir das verdrahten, steht unter Ops-Automation.
Wann RLS nicht reicht
RLS trennt Zeilen, aber es ersetzt keine Anwendungslogik. Komplexe Berechtigungen mit Rollen, geteilten Datensätzen oder hierarchischen Mandanten lassen sich zwar in Policies abbilden, werden aber schnell unübersichtlich und langsam, weil jede Policy bei jeder Abfrage läuft. Ab einem gewissen Punkt gehört Teil der Autorisierung in eine bewusst abgesicherte Server-Schicht.
Die ehrliche Einordnung lautet: RLS ist eine starke, datenbanknahe Verteidigungslinie, die du immer brauchst, aber selten als einzige. Kombiniert mit server-seitigen Aufrufen über API-Pipelines und sauberem Schlüsselmanagement ergibt sich eine Trennung, die hält. Wenn du dein bestehendes Supabase-Projekt darauf prüfen lassen willst, melde dich über Kontakt.