Zum Inhalt springen
Europe/Berlin
--:--:--

PostgreSQL Multi-Tenant RLS: 7 Best Practices

October 17, 2026

In modernen SaaS-Architekturen steht man früher oder später vor der Grundsatzfrage: Wie isolieren wir Mandantendaten verlässlich, ohne die Betriebskosten und die Komplexität ins Unermessliche zu treiben?

Lange Zeit galt das Paradigma, separate Datenbanken oder Schemata pro Mandant aufzusetzen. Aus operativer Sicht rächt sich das jedoch schnell. PostgreSQL bietet mit Row-Level Security (RLS) seit Version 9.5 eine native Lösung auf Zeilenebene, die maximale Ressourceneffizienz verspricht. Doch wer RLS leichtfertig ohne Verständnis für den PostgreSQL-Query-Optimizer, PgBouncer oder Foreign Keys einführt, läuft in gravierende Sicherheitslücken und Performance-Fallen.

In diesem Artikel zeige ich dir anhand konkreter Praxiserfahrungen, worauf es beim Aufbau einer robusten Multi-Tenant-Architektur mit RLS wirklich ankommt.

Bevor wir in DDL und SQL-Queries abtauchen, ordnen wir die drei gängigen Mandanten-Modelle ein:

  1. Database-per-Tenant: Maximale Isolation, da jeder Mandant seine eigene PostgreSQL-Instanz oder -Datenbank erhält. Der Nachteil ist ein enormer Ressourcen-Overhead für Connection-Pools, Memory (Shared Buffers) und Betriebskosten. Wartung und Migrationen über hunderte Datenbanken werden zum Albtraum.
  2. Schema-per-Tenant: Mandanten teilen sich eine Datenbank, bekommen aber eigene PostgreSQL-Schemata. Klingt nach einem guten Kompromiss, scheitert in der Praxis jedoch an Systemgrenzen. Ab etwa 1.000 bis 2.000 Schemata geraten DDL-Migrationen und interne PostgreSQL-Strukturen wie der catcache (Systemkatalog-Cache) an ihre Limits. Queries werden spürbar langsamer, weil PostgreSQL unzählige Metadaten vorhalten muss.
  3. Shared-Schema mit Row-Level Security (RLS): Alle Mandanten teilen sich dieselben Tabellen. Eine Spalte tenant_id kennzeichnet die Zugehörigkeit. RLS fungiert als datenbankinterner Guard, der Queries automatisiert transparent filtert. Die Ressourcenkosten sind minimal, Backups und Schema-Migrationen skalieren trivial wie bei einer Single-Tenant-Applikation.

Ein klassisches Szenario: Ein Mandant (organizations) besitzt Dokumente (documents). Wer RLS einsetzt, muss Tabellen explizit vorbereiten und indizieren. Ohne B-Tree-Index auf der Mandantenspalte ist RLS schlicht unbrauchbar.

Sql
-- Basis-Tabellen anlegen
CREATE TABLE organizations (
    id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    name text NOT NULL
);

CREATE TABLE documents (
    id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id uuid NOT NULL REFERENCES organizations(id),
    title text NOT NULL
);

-- Unverzichtbar für OLTP-Performance: Zusammengesetzter Index
CREATE INDEX idx_documents_tenant_id_id ON documents (tenant_id, id);

-- RLS aktivieren und erzwingen
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents FORCE ROW LEVEL SECURITY;

Ein ordentlich indiziertes RLS-Setup erzeugt in OLTP-Szenarien lediglich 2 bis 7 Prozent Latenz-Overhead im Vergleich zu nativen Queries. Fehlt der Index auf tenant_id, scannt PostgreSQL die Tabelle sequentiell – Latenzsteigerungen um den Faktor 10x bis 100x sind die unausweichliche Folge.

Ein massiver Stolperstein: Tabellen-Owner und Superuser umgehen RLS standardmäßig (BYPASSRLS). Viele Teams testen ihr RLS-Setup lokal mit dem postgres-Adminuser, stellen fest, dass Tenant A die Daten von Tenant B sieht, und halten RLS für defekt – oder schlimmer: Die Applikation verbindet sich im Produktivbetrieb als Tabellen-Owner, wodurch der Schutz komplett ausgehebelt wird.

Das Statement ALTER TABLE documents FORCE ROW LEVEL SECURITY; verhindert, dass selbst der Owner der Tabelle RLS umgeht. Für die Produktivumgebung gilt dennoch zwingend: Nutze eine separate Applikationsrolle ohne Superuser- oder Bypass-Rechte.

Die RLS-Policy bestimmt, welche Zeilen ein Benutzer sehen und manipulieren darf. Hier entscheidet die Formulierung darüber, ob der Query-Planer skaliert oder die Datenbank in die Knie zwingt.

Sql
CREATE POLICY tenant_isolation_policy ON documents
    FOR ALL
    USING (tenant_id = (SELECT NULLIF(current_setting('app.current_tenant_id', true), '')::uuid))
    WITH CHECK (tenant_id = (SELECT NULLIF(current_setting('app.current_tenant_id', true), '')::uuid));

Achte penibel auf das Scalar Subquery: (SELECT NULLIF(...) ).

Schreibst du stattdessen naiv tenant_id = NULLIF(...) ohne Subquery-Klammern, interpretiert der Query Optimizer den Aufruf oft als SubPlan. Das bedeutet: Die Funktion current_setting wird für jede einzelne Tabellenzeile im Scan evaluiert. Bei Millionen Records führt das zu massiven CPU-Spitzen.

Wickelst du den Ausdruck in ein (SELECT ...), erkennt PostgreSQL den Wert als konstant für die gesamte Abfrage. Im EXPLAIN-Plan taucht die Auswertung als InitPlan auf: Der Wert wird exakt einmal berechnet und anschließend im Index-Scan als Konstante wiederverwendet.

Um den Mandantenkontext an die Policy zu übergeben, setzen Backend-Services Session-Variablen via current_setting. Im Production-Backend läuft PostgreSQL jedoch selten ohne Connection-Pooler wie PgBouncer im Transaction-Pooling-Modus.

Ein fataler Fehler ist hier der Aufruf von:

Sql
-- GEFÄHRLICH im Transaction Pooling!
SET app.current_tenant_id = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11';

SET modifiziert die physische TCP-Verbindung. Gibt PgBouncer die Verbindung nach der Transaktion an einen anderen Mandanten-Request zurück, erbt dieser unter Umständen die Tenant-ID des vorherigen Requests (Session Pollution). Ein katastrophaler Datenleck-Vektor!

Verwende zwingend SET LOCAL oder die Funktion set_config(..., true) innerhalb eines expliziten Transaktionsblocks. Der dritte Parameter is_local = true garantiert, dass der Wert beim COMMIT oder ROLLBACK restlos verworfen wird:

Sql
BEGIN;
SELECT set_config('app.current_tenant_id', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', true);

SELECT * FROM documents;

COMMIT;

Wer moderne Backend-as-a-Service-Stacks wie Supabase oder PostgREST nutzt, schleift Mandanten-Claims meist direkt über signierte JSON Web Tokens (JWT) ein. Die Datenbank extrahiert die tenant_id dabei ohne zusätzliche Applikations-Roundtrips direkt aus dem Auth-Kontext:

Sql
CREATE POLICY supabase_jwt_tenant_policy ON documents
    FOR ALL
    TO authenticated
    USING (tenant_id = (SELECT (nullif(current_setting('request.jwt.claims', true), '')::jsonb -> 'app_metadata' ->> 'tenant_id')::uuid));

Auch hier greift das InitPlan-Muster über die skalare Subquery. Die Datenbank greift auf das Claims-Objekt zu, liest die Mandanten-ID aus und filtert die Ergebnismenge strikt ab – abgesichert auf Krypto-Ebene durch die Signatur des Tokens.

Sobald RLS-Logiken komplexer werden (etwa bei Rollen-Hierarchien oder Feature-Flags), lagern Teams Prüfungen gerne in eigene PL/pgSQL- oder SQL-Funktionen aus. Dabei lauert die nächste Optimizer-Falle.

PostgreSQL schützt RLS-Policies vor Datenlecks via Error-Reporting (Side-Channel-Attacks). Wenn eine User-Query Bedingungen wie WHERE 1/(case when secret_col = 'admin' then 0 else 1 end) = 1 enthält, könnte ein Angreifer Rückschlüsse auf Zeilen ziehen, die er eigentlich nicht sehen darf. Daher weigert sich PostgreSQL, Bedingungen vor der RLS-Policy auszuführen, es sei denn, eine Hilfsfunktion ist explizit als LEAKPROOF markiert.

Damit deine Funktionen performant durch Funktions-Inlining ausgeführt werden:

  • Deklariere sie in reinem LANGUAGE sql statt plpgsql (wenn möglich).
  • Kennzeichne sie zwingend als STABLE oder IMMUTABLE.
  • Markiere sie (nur als Superuser möglich) als LEAKPROOF, wenn verifiziert ist, dass die Funktion keine Seiteneffekte oder informative Fehlermeldungen provoziert.

Fehlen diese Attribute, kann PostgreSQL den RLS-Check nicht in den restlichen Query-Tree inlinen, was Pushdown-Prädikate verhindert und Joins extrem verlangsamt.

Row-Level Security schützt Zeilen vor direktem Zugriff. Was viele Engineers übersehen: Standardmäßige Foreign Keys prüfen nicht, ob das referenzierte Zielobjekt demselben Mandanten gehört!

Ein konkretes Fehlerszenario: Ein Angreifer aus Tenant A manipuliert eine API-Payload und setzt beim Erstellen eines Rechnungs-Items die document_id eines Dokuments aus Tenant B. Wenn die Tabelle documents RLS besitzt, kann Tenant A das fremde Dokument zwar nicht via SELECT sehen. Führt er jedoch ein INSERT INTO invoice_items (document_id, ...) VALUES (...) aus, prüft die Foreign-Key-Constraint die Existenz der Zeile global über den internen Index – und das INSERT geht durch!

Um solche Cross-Tenant-Referenzen auf Datenbankebene physikalisch unmöglich zu machen, müssen Foreign Keys und Primary Keys zwingend als Composite Keys modelliert werden:

Sql
-- Parent-Tabelle mit Composite Key absichern
ALTER TABLE documents 
    DROP CONSTRAINT documents_pkey CASCADE,
    ADD CONSTRAINT documents_pkey PRIMARY KEY (tenant_id, id);

-- Child-Tabelle erfordert immer zwingend die Mandanten-Prüfung
CREATE TABLE invoice_items (
    id uuid DEFAULT gen_random_uuid(),
    tenant_id uuid NOT NULL,
    document_id uuid NOT NULL,
    description text NOT NULL,
    PRIMARY KEY (tenant_id, id),
    CONSTRAINT fk_invoice_document 
        FOREIGN KEY (tenant_id, document_id) 
        REFERENCES documents (tenant_id, id) 
        ON DELETE CASCADE
);

Damit ist es unmöglich, dass ein Datensatz aus Mandant A auf eine Ressource von Mandant B zeigt, selbst wenn ein Entwickler im Backend vergisst, die Mandantenbindung im Service-Layer abzuprüfen.

Dieses Schema mit Composite Keys ist identisch mit dem Colocation-Modell von Citus (Distributed PostgreSQL). Wenn deine Multi-Tenant-Datenbank über mehrere Nodes hinweg geshardet werden soll, verlangt Citus genau diesen Aufbau: Jede Tabelle wird über tenant_id verteilt. In solchen Setups dient RLS als elementare Defense-in-Depth-Schicht, während Citus für die horizontale Skalierbarkeit sorgt.

Row-Level Security in PostgreSQL ist kein magischer Schalter, den man einfach umlegt. Es ist ein hochgradig effektives Architekturwerkzeug, das sauberes SQL-Handwerk erfordert. Sobald du FORCE ROW LEVEL SECURITY etablierst, deine Policies auf InitPlan-Ausführung trimmst, Connection-Pools mit set_config(..., true) absicherst und Cross-Tenant-Leaks via Composite Foreign Keys verhinderst, erhältst du eine extrem skalierbare, ressourcenschonende und hieb- und stichfeste Multi-Tenant-Plattform.

Wenn du vorhast, deine SaaS-Datenbank auf PostgreSQL RLS umzustellen oder Sicherheitslücken in deiner Multi-Tenant-Architektur schließen willst, lass uns deine Infrastruktur gemeinsam zukunftssicher aufstellen: Kontaktiere mich auf buengener-software.de.