Sichere Multi-Tenant-SaaS mit Claude Code: RLS, Auth, Billing, Jobs und Logs
Baue Multi-Tenant-SaaS mit Claude Code: tenant_id, RLS, Auth-Grenzen, Planlimits, Jobs, Logs und Tests.
Multi-Tenant-SaaS bedeutet, dass eine Anwendung mehrere Kundenorganisationen bedient. Ein Tenant ist dabei die Datengrenze des Kunden: Firma, Workspace, Kundenportal oder Account. Claude Code kann Routen, Datenbankhelfer, Hintergrundjobs und Oberflächen sehr schnell verändern. Genau deshalb muss die Tenant-Grenze vor der Implementierung klar beschrieben sein.
Der kritische Vorfall ist einfach: Ein Nutzer aus Tenant A kann Projekte, Rechnungen, Dateien, Audit-Events oder KI-Prompts von Tenant B lesen. Im Review nur nachwhere tenantIdzu suchen reicht nicht aus. Eine robuste Architektur propagierttenant_iddurch Authentifizierung, Session, Datenbankzugriff, Billing, Jobs, Suche und Logs. Danach blockiert die Datenbank als letzte Schranke, wenn eine Anwendungsklausel fehlt.
Diese Anleitung nutzt Claude Code als Implementierungs- und Review-Partner, nicht als Abkürzung um Security. RLS steht für Row Level Security, also zeilenbasierte Zugriffskontrolle in PostgreSQL. Nutze die offiziellen PostgreSQL-Dokumente zuRow Security Policies, CREATE POLICY undcurrent_setting / set_config, wenn du die Snippets anpasst.
Als Grundlage passen Authentifizierung mit Claude Code, RBAC mit Claude Code und Datenbankmigrationen. Multi-Tenancy verbindet diese Themen.
Zuerst Das Isolationsmodell Wählen
Es gibt drei übliche Modelle. Wähle eines, bevor Claude Code Handler oder Schemas editiert.
| Modell | Geeignet für | Stärke | Falle |
|---|---|---|---|
| Gemeinsame DB, gemeinsames Schema | Frühe und mittlere SaaS mit vielen kleinen Kunden | Geringe Betriebskosten, einfache Analysen | Ein fehlender Tenant-Filter kann Daten leaken |
| Gemeinsame DB, Schema pro Tenant | Wenige große Kunden mit stärkerer Trennung | Rechte und Migrationen etwas getrennt | Viele Schemas machen Releases schwer |
| DB pro Tenant | Regulierte Branchen, strenge Verträge | Backup, Schlüssel und Ausfälle trennbar | Kosten und Deployment-Komplexität steigen |
Die praktische Frage lautet: Funktioniert das Modell noch bei 100 oder 1000 Kunden? Für die meisten kleinen und mittleren SaaS-Produkte ist gemeinsame DB mit gemeinsamem Schema realistisch, wenntenant_id, RLS, Audit Logs und Restore-Prozesse stark sind. Wenn Enterprise-Kunden physische Trennung verlangen, plane einen Pfad für einzelne Tenants in separate Datenbanken.
Drei Use Cases zeigen die Unterschiede. Erstens ein B2B-CRM: Accounts, Kontakte, Opportunities und Notizen gehören zu einem Tenant. Zweitens ein Agenturportal: Agenturmitarbeiter können mehreren Tenants angehören, Kundenmitarbeiter nur einem Portal. Drittens eine KI-SaaS: Prompt-Läufe, Tokens, Dateien und Speicher werden pro Tenant gezählt, damit Billing und Planlimits stimmen.
Beginne mit einem Design-Review-Prompt.
Ich möchte diese SaaS-Codebase multi-tenant machen.
Gehe von gemeinsamem PostgreSQL und gemeinsamem Schema aus.
Klassifiziere Tabellen in tenant-owned, global reference und Tabellen mit PostgreSQL RLS.
Gefährliche Abkürzungen sind verboten: keine Aussage "Application-WHERE reicht", kein vollständiges Request-Body-Logging, keine Jobs ohne tenant_id.
Gib Änderungsbereiche, DB-Constraints, Testfälle und Migrationsrisiken aus.
tenant_id Aus Einer Vertrauenswürdigen Quelle Propagieren
Vertraue keinem Browser-Headerx-tenant-idund keinem zufälligen Routenparameter. Der sichere Ablauf ist: Tenant-Kandidat aus Host oder Pfad auflösen, serverseitig prüfen, ob der eingeloggte Nutzer Mitglied ist, und nur den bestätigtentenantIdan Services übergeben.
Auth.js zeigt inExtending the Session, wie Sessions erweitert werden. Halte die Session trotzdem klein. Sie identifiziert den Nutzer; Tenant-Mitgliedschaft und Rollen sollten aus serverseitigen Daten gelesen werden, weil sie sich ändern können.
// src/lib/tenant-context.ts
import { z } from "zod";
const hostSchema = z.string().min(1).max(255);
export type SessionUser = {
id: string;
email: string;
};
export type TenantContext = {
tenantId: string;
userId: string;
role: "owner" | "admin" | "member" | "viewer";
requestId: string;
};
type TenantRecord = {
id: string;
subdomain: string | null;
custom_domain: string | null;
};
type MembershipRecord = {
tenant_id: string;
user_id: string;
role: TenantContext["role"];
};
export async function resolveTenantContext(input: {
request: Request;
sessionUser: SessionUser | null;
requestId: string;
findTenantByHost: (host: string) => Promise<TenantRecord | null>;
findMembership: (tenantId: string, userId: string) => Promise<MembershipRecord | null>;
}): Promise<TenantContext> {
if (!input.sessionUser) {
throw new Response("Unauthorized", { status: 401 });
}
const host = hostSchema.parse(new URL(input.request.url).host.toLowerCase());
const tenant = await input.findTenantByHost(host);
if (!tenant) {
throw new Response("Tenant not found", { status: 404 });
}
const membership = await input.findMembership(tenant.id, input.sessionUser.id);
if (!membership) {
throw new Response("Forbidden for this tenant", { status: 403 });
}
return {
tenantId: tenant.id,
userId: input.sessionUser.id,
role: membership.role,
requestId: input.requestId,
};
}
Wichtig ist, was der Code nicht tut: Er glaubt dem Client-Tenant nicht. Interne APIs und Worker brauchen dieselbe Regel. Ein externer tenant_id ist eine Behauptung; Membership, ein gebundener API-Key oder ein signiertes Job-Payload muss sie bestätigen.
PostgreSQL RLS Als Letzte Barriere Nutzen
Application-Filter sind weiterhin sinnvoll, aber ein vergessener Filter darf kein Datenleck werden. In einem gemeinsamen Schema braucht jede tenant-owned Tabelletenant_id, Foreign Keys, Tenant-Indizes und RLS-Policies. In PostgreSQL können Tabellenbesitzer und Rollen mitBYPASSRLSRLS umgehen, daher sollte die App-Rolle die Tabellen nicht besitzen. Für kritische Tabellen nutzeFORCE ROW LEVEL SECURITY.
-- db/migrations/20260602_multi_tenant_rls.sql
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE tenants (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
name text NOT NULL,
plan text NOT NULL DEFAULT 'starter',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE app_users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE tenant_memberships (
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
role text NOT NULL CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, user_id)
);
CREATE TABLE projects (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name text NOT NULL,
status text NOT NULL DEFAULT 'active',
created_by uuid NOT NULL REFERENCES app_users(id),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX projects_tenant_id_id_idx ON projects (tenant_id, id);
CREATE INDEX tenant_memberships_user_id_tenant_id_idx ON tenant_memberships (user_id, tenant_id);
ALTER TABLE tenant_memberships ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant_memberships FORCE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_memberships_isolation
ON tenant_memberships
FOR ALL
USING (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid);
CREATE POLICY projects_isolation
ON projects
FOR ALL
USING (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid);
Bei Connection Pools muss der Tenant-Kontext transaktionslokal sein. Ein Session-Wert kann auf einer wiederverwendeten Verbindung bleiben;set_config(..., true)gilt nur bis zum Ende der Transaktion.
// src/db/tenant-db.ts
import { Pool, PoolClient } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
const uuidPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export async function withTenant<T>(
tenantId: string,
work: (client: PoolClient) => Promise<T>,
): Promise<T> {
if (!uuidPattern.test(tenantId)) {
throw new Error("Invalid tenant id");
}
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query("SELECT set_config('app.tenant_id', $1, true)", [tenantId]);
const result = await work(client);
await client.query("COMMIT");
return result;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function listProjects(tenantId: string) {
return withTenant(tenantId, async (db) => {
const result = await db.query(
"SELECT id, name, status, created_at FROM projects ORDER BY created_at DESC",
);
return result.rows;
});
}
Die Beispielquery enthält bewusst keinWHERE tenant_id = $1. Wenn RLS aktiv ist, filtert PostgreSQL unsichtbare Zeilen. In Produktion kannst du zusätzliche Filter für Performance nutzen, aber nicht als Ersatz für RLS.
Auth, Billing Und Jobs Auf Dieselbe Grenze Bringen
Bei Auth geht es nicht nur um Login, sondern um Rechte in diesem Tenant. Einuser_idallein ist gefährlich, wenn Agenturen, Consultants oder Support mehreren Tenants angehören. GibtenantId, userId, roleundrequestIddurch Servicegrenzen weiter.
Billing nutzt dieselbe Grenze. Stripe beschreibtusage-based billing, aber deine App muss zuerst die richtige Tenant-Nutzung zählen. Planlimits müssen serverseitig vor Writes oder KI-Läufen greifen, nicht nur in der UI.
// src/lib/plan-limits.ts
type Plan = "starter" | "growth" | "enterprise";
type Metric = "users" | "projects" | "aiRuns";
const PLAN_LIMITS: Record<Plan, Record<Metric, number>> = {
starter: { users: 5, projects: 20, aiRuns: 500 },
growth: { users: 50, projects: 500, aiRuns: 10_000 },
enterprise: { users: 10_000, projects: 1_000_000, aiRuns: 50_000_000 },
};
export function assertPlanLimit(input: {
plan: Plan;
metric: Metric;
currentUsage: number;
increment?: number;
}) {
const nextUsage = input.currentUsage + (input.increment ?? 1);
const limit = PLAN_LIMITS[input.plan][input.metric];
if (nextUsage > limit) {
throw new Response(
JSON.stringify({
error: "PLAN_LIMIT_EXCEEDED",
metric: input.metric,
limit,
nextUsage,
}),
{ status: 402, headers: { "content-type": "application/json" } },
);
}
}
Hintergrundjobs sind eine typische Leckquelle. Lege nicht nurprojectIdin die Queue und lasse den Worker global suchen. Speichere den geprüftentenantIdim Payload und setze im Worker wieder den RLS-Kontext.
// src/jobs/send-project-digest.ts
import { withTenant } from "../db/tenant-db";
type ProjectDigestJob = {
tenantId: string;
projectId: string;
requestedBy: string;
};
export async function handleProjectDigestJob(job: ProjectDigestJob) {
return withTenant(job.tenantId, async (db) => {
const project = await db.query(
"SELECT id, name FROM projects WHERE id = $1",
[job.projectId],
);
if (project.rowCount !== 1) {
throw new Error("Project not visible for tenant");
}
await db.query(
"INSERT INTO audit_events (tenant_id, actor_user_id, event_name) VALUES ($1, $2, $3)",
[job.tenantId, job.requestedBy, "project_digest_sent"],
);
});
}
Genug Loggen, Nicht Alles
Logs werden schnell zur zweiten Datenbank mit schwächeren Kontrollen. Das OWASPLogging Cheat Sheet betont Sanitizing und das Weglassen sensibler Daten. In Multi-Tenant-SaaS brauchst dutenantId, requestId, Event, Actor und Target. Du brauchst keine Cookies, Authorization Header, API Keys, vollständigen Prompts, Request Bodies oder Rechnungsadressen.
// src/lib/safe-log.ts
type LogLevel = "info" | "warn" | "error";
const SENSITIVE_KEYS = new Set([
"authorization",
"cookie",
"password",
"token",
"apiKey",
"prompt",
"secret",
]);
function redact(value: unknown): unknown {
if (!value || typeof value !== "object") return value;
if (Array.isArray(value)) return value.map(redact);
return Object.fromEntries(
Object.entries(value).map(([key, child]) => [
key,
SENSITIVE_KEYS.has(key) ? "[REDACTED]" : redact(child),
]),
);
}
export function tenantLog(level: LogLevel, message: string, fields: Record<string, unknown>) {
const safeFields = redact(fields);
console[level](JSON.stringify({ message, ...safeFields }));
}
Trenne Audit Logs von operativen Logs. Audit Logs beantworten wer was, in welchem Tenant, an welchem Ziel und wann getan hat. Kopiere nicht komplette Datensätze, wenn es keinen klaren Grund gibt.
Tests Schreiben, Die Lecks Suchen
Happy-Path-Tests reichen nicht. Teste Tenant A mit IDs von Tenant B, Worker mit falschem Payload, fehlendesapp.tenant_id, Planlimit-Überschreitung und Logger mit sensiblen Feldern.
-- db/tests/rls-smoke-test.sql
\set ON_ERROR_STOP on
BEGIN;
INSERT INTO tenants (id, slug, name)
VALUES
('00000000-0000-4000-8000-000000000001', 'alpha', 'Alpha Inc'),
('00000000-0000-4000-8000-000000000002', 'beta', 'Beta Inc');
INSERT INTO app_users (id, email)
VALUES ('10000000-0000-4000-8000-000000000001', 'masa@example.com');
INSERT INTO projects (id, tenant_id, name, created_by)
VALUES
('20000000-0000-4000-8000-000000000001', '00000000-0000-4000-8000-000000000001', 'Alpha project', '10000000-0000-4000-8000-000000000001'),
('20000000-0000-4000-8000-000000000002', '00000000-0000-4000-8000-000000000002', 'Beta project', '10000000-0000-4000-8000-000000000001');
SELECT set_config('app.tenant_id', '00000000-0000-4000-8000-000000000001', true);
DO $$
DECLARE visible_count integer;
BEGIN
SELECT count(*) INTO visible_count FROM projects;
IF visible_count <> 1 THEN
RAISE EXCEPTION 'RLS failed: expected 1 visible project, got %', visible_count;
END IF;
END $$;
ROLLBACK;
#!/usr/bin/env bash
set -euo pipefail
: "${DATABASE_URL:?DATABASE_URL is required}"
psql "$DATABASE_URL" -f db/migrations/20260602_multi_tenant_rls.sql
psql "$DATABASE_URL" -f db/tests/rls-smoke-test.sql
Gib Claude Code bewusst gegnerische Kriterien.
Füge Multi-Tenant-Leak-Tests hinzu:
1. Tenant-A-Session mit Tenant-B-projectId gibt 403 oder 404 zurück.
2. DB-Query ohne app.tenant_id liefert null Zeilen oder fail closed.
3. Background Job ohne tenantId wird abgelehnt.
4. Planlimit-Überschreitung gibt vor dem Write 402 zurück.
5. Logs enthalten nie authorization, cookie, prompt, token oder apiKey.
Erkläre zuerst, welche falsche Implementierung jeder Test findet, dann Befehle und Ergebnisse.
Migrationsfallen Vermeiden
Beim Nachrüsten ist der häufigste Fehler ein nullabletenant_id, schneller Release und späteres Aufräumen. Temporäre Nullability kann im Backfill nötig sein, aber der Release ist erst fertig mitNOT NULL, Foreign Keys, tenant-aware Unique Constraints, Indizes, RLS und Tests.
Achte auf fünf Fallen. Erstens: globale Referenzdaten und Tenant-Daten vermischen. Ländercodes können global sein, Kundeneinstellungen nicht. Zweitens: File-Storage-Keys ohne Tenant-Kontext. Drittens: Suchindizes ohnetenant_idfür Algolia, Meilisearch oder OpenSearch. Viertens: RLS im Admin-Panel abschalten; Support-Zugriff muss explizit und auditiert sein. Fünftens: Restore pro Tenant nicht testen.
Erstelle einen Migrationsplan für bestehende Tabellen zu Multi-Tenancy.
Phase 1: tenant_id-Spalten hinzufügen und bestehende Datensätze mappen.
Phase 2: Backfill SQL und Validierungs-SQL für verbleibende NULLs schreiben.
Phase 3: NOT NULL, Foreign Keys, tenant-aware Unique Constraints und Indizes ergänzen.
Phase 4: RLS aktivieren und FORCE ROW LEVEL SECURITY vorschlagen.
Phase 5: APIs, Jobs, Logs und Suchindizes auf Cross-Tenant-Leaks testen.
Jede Phase braucht Rollback-Bedingungen und Verification SQL.
Sichere Claude-Code-Prompts Verwenden
Auch der Prompt braucht die Tenant-Grenze. Vermeide “mach das schnell multi-tenant” oder “Admins sehen alles”. Ein sicherer Prompt enthält Verbote, Tests, Quellen und Scope.
Du implementierst sicheres Multi-Tenant-SaaS-Verhalten.
Erlaubter Scope: src/app, src/lib, db/migrations, tests.
Pflichten:
- tenant_id serverseitig auflösen, keine Client-Tenant-IDs vertrauen.
- PostgreSQL RLS für jede tenant-owned Tabelle nutzen.
- Bei Connection Pools set_config('app.tenant_id', value, true) in einer Transaktion aufrufen.
- tenantId durch Auth, Billing, Jobs, Logs und Search Indexing propagieren.
- Tests ergänzen, die Cross-Tenant-Lese- und Schreibzugriffe versuchen.
Verboten:
- RLS deaktivieren.
- x-tenant-id vertrauen.
- Authorization, Cookie, API keys oder vollständige Prompts loggen.
- Ohne Tests fertig melden.
Der Abschlussbericht enthält geänderte Dateien, ausgeführte Befehle, gefundene Fehlerfälle und Restrisiko.
Die Anthropic-DokumentationClaude Code Security behandelt Berechtigungen, Prompt Injection und Review-Verantwortung. Für sensible SaaS-Repos sollten erlaubte Befehle, MCP-Verbindungen und Projektregeln versioniert und reviewbar sein.
CTA Und Übergabe
Multi-Tenancy ist nicht die auffälligste Funktion, verkauft aber Vertrauen im B2B-SaaS. Wer Tenant-Isolation, Audit Logs, Planlimits, Support-Zugriff und Restore-Grenzen erklären kann, beantwortet Security-Fragebögen deutlich leichter.
Teams sollten Tenant-Regeln inCLAUDE.mdfesthalten und mit Tests erzwingen. Im Review immer fragen: Wirdtenant_idpropagiert, blockiert RLS Fehler, sind Jobs scoped, und vermeiden Logs sensible Daten? Teams können mitClaude Code Training und Beratung starten. Einzelne Entwickler können diekostenlose Cheatsheet nutzen, um daraus wiederholbare Prompts zu machen.
Ergebnis Aus Dem Praxistest
Als Masa dieses Muster an einem kleinen CRM testete, lag das erste Leck nicht in der API-Route, sondern im Daily-Digest-Worker. Die UI übergabtenantId, aber das Queue-Payload enthielt nurprojectId. Dadurch konnte der Worker außerhalb des erwarteten Tenants suchen. Nachdem auch Worker durchwithTenantliefen und ein SQL-Test für fehlendesapp.tenant_idexistierte, war der Fehler reproduzierbar und behoben. Der beste Claude-Code-Auftrag war nicht “füge Happy-Path-Tests hinzu”, sondern “schreibe zuerst einen Test, in dem Tenant A Tenant B lesen will”.
Kostenloses PDF: Claude-Code-Cheatsheet
E-Mail eintragen und eine Seite mit Befehlen, Review-Gewohnheiten und sicheren Workflows herunterladen.
Wir schützen Ihre Daten und senden keinen Spam.
Über den Autor
Masa
Engineer für praktische Claude-Code-Workflows und Team-Einführung.
Ähnliche Artikel
Claude Code Workflow von Obsidian zu CLAUDE.md
Obsidian-Arbeitsnotizen in CLAUDE.md-Betriebsnotizen verwandeln und Kontext nicht ständig neu erklären.
Claude Code Revenue CTA Routing: Artikel zu PDF, Gumroad und Beratung führen
Ein Claude-Code-Ablauf, der Leser nach Absicht zu Gratis-PDF, Gumroad oder Beratung führt.
Claude-Code-Team-Handoff-Regeln: Belege, Berechtigungen, Rollback und Umsatzpfade
Ein praktisches Claude-Code-Handoff für Review-Belege, Berechtigungen, Rollback, Gratis-PDF, Gumroad und Beratung.