Safe Multi-Tenant SaaS with Claude Code: RLS, Auth, Billing, Jobs, and Logs
Build tenant isolation with Claude Code: RLS, auth boundaries, billing limits, jobs, logs, and leak tests.
Multi-tenant SaaS means one application serves many customer organizations. A tenant is the customer boundary: the company, workspace, client portal, or account that owns a slice of the data. Claude Code can move very quickly across routes, database helpers, jobs, and UI screens, so the tenant boundary must be written down before implementation starts.
The incident to avoid is simple: a user from Tenant A can read a project, invoice, file, audit event, or AI prompt from Tenant B. Searching a code review for where: { tenantId } is not enough. The safer design propagates tenant_id through authentication, sessions, database access, billing, background jobs, search, and logs, then uses the database as the final guard when an application query is wrong.
This guide treats Claude Code as a practical implementation partner, not as a shortcut around security review. RLS means Row Level Security: PostgreSQL policies that decide which rows are visible or writable for the current database context. Use the official PostgreSQL Row Security Policies, CREATE POLICY, and current_setting / set_config docs as the primary references when you adapt the snippets.
For adjacent foundations, read authentication with Claude Code, role-based access control, and database migrations. Multi-tenancy is where all three topics meet.
Choose The Isolation Model First
There are three common isolation models. Pick one before asking Claude Code to edit handlers or schemas.
| Model | Best fit | Strength | Main pitfall |
|---|---|---|---|
| Shared database, shared schema | Early and mid-stage SaaS with many small tenants | Low operational cost and simple analytics | One missing tenant filter can expose data |
| Shared database, schema per tenant | B2B apps with a few large customers needing stronger separation | Some operational and permission separation | Migrations become harder as schemas multiply |
| Database per tenant | Regulated customers, strict contracts, regional isolation | Backup, encryption, and outage blast radius can be separated | Higher cost and deployment complexity |
The first practical question is whether the model still works after 100 or 1,000 customers. Most small and mid-size SaaS products should start with a shared database and shared schema, then make tenant_id, RLS, audit logging, and restore procedures strong. If a contract requires physical separation, design an escape path for selected enterprise tenants instead of pretending that every customer needs a separate database on day one.
Use case one is a B2B CRM. Accounts, contacts, opportunities, notes, and activities all belong to one tenant, while a salesperson can only see their company’s rows. Use case two is an agency client portal. Agency staff may belong to several tenants, but the customer’s employee must only see their own portal. Use case three is an AI-enabled SaaS. Prompt runs, token usage, uploaded files, and storage must be counted per tenant so billing and plan limits are correct.
Start Claude Code with a design-review prompt, not an implementation prompt.
I want to make this SaaS codebase multi-tenant.
Assume a shared PostgreSQL database and shared schema.
Classify tables into tenant-owned tables, global reference tables, and tables that need PostgreSQL RLS.
Dangerous shortcuts are not allowed: no "application WHERE clauses are enough", no full request-body logging, and no background jobs that infer tenant_id later.
Return changed areas, database constraints, test cases, and migration risks.
Propagate tenant_id From A Trusted Source
Do not trust a browser-provided x-tenant-id header or a random route parameter as the tenant boundary. The safer flow is: resolve a candidate tenant from the host or path, verify on the server that the signed-in user is a member of that tenant, then pass only the verified tenantId into application services.
Auth.js documents how to extend sessions with server-derived fields in its official Extending the Session guide. Keep the session small. It should identify the user; tenant membership should still be checked against server-side data because memberships, roles, and suspended tenants can change.
// 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,
};
}
The important detail is what the code refuses to do: it does not accept tenantId from the client as truth. Internal APIs and background workers should follow the same rule. A tenant identifier from outside the trust boundary is a claim; membership, an API key binding, or a signed job payload must prove it.
Make PostgreSQL RLS The Backstop
Application filters are still useful, but one missed filter should not become a breach. In a shared-schema model, every tenant-owned table needs tenant_id, foreign keys, tenant-aware indexes, and RLS policies. PostgreSQL table owners and roles with BYPASSRLS can bypass row security, so the application role should not own the protected tables. For critical tables, use FORCE 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);
Set the tenant context inside a transaction. This matters with connection pools: a session-level value can accidentally survive on a reused connection, while set_config(..., true) is local to the transaction.
// 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;
});
}
The example intentionally omits WHERE tenant_id = $1 from the query. If RLS is active, PostgreSQL filters invisible rows. In production you may still include tenant filters for performance and index selectivity, but they are not a substitute for RLS.
Align Auth, Billing, And Background Jobs
Authentication is not just “is the user signed in?” The real question is “what can this user do inside this tenant?” A user ID alone is dangerous when consultants, agencies, and support staff can belong to multiple tenants. Pass tenantId, userId, role, and requestId through service boundaries.
Billing follows the same boundary. Stripe’s usage-based billing centers on reporting usage, but your app must first count the correct tenant’s usage. Plan limits must be enforced on the server before writes or AI runs happen, not only shown in the 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" } },
);
}
}
Background jobs are a common leak path. Do not enqueue only projectId and let the worker search globally later. Put the verified tenantId into the job payload and set the RLS context again in the worker.
// 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"],
);
});
}
Log Enough, Not Everything
Logs often become a second database with weaker controls. OWASP’s Logging Cheat Sheet emphasizes sanitizing event data and avoiding sensitive data in logs. In multi-tenant SaaS, you usually need tenantId, requestId, event name, actor ID, and target ID. You do not need cookies, authorization headers, API keys, full prompts, full request bodies, or billing addresses.
// 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 }));
}
Keep audit logs separate from operational logs. Audit logs should answer who did what, in which tenant, to which target, and when. They should not become a full copy of the changed record unless regulation or product requirements demand it.
Write Tests That Try To Leak Data
Happy-path tests are not enough. Add tests where Tenant A tries to access Tenant B’s ID, a worker receives a mismatched payload, app.tenant_id is missing, a plan limit is exceeded, and a logger receives sensitive fields.
-- 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
Give Claude Code adversarial acceptance criteria.
Add multi-tenant leak tests with these cases:
1. A Tenant A session using a Tenant B projectId returns 403 or 404.
2. A DB query without app.tenant_id returns zero rows or fails closed.
3. A background job without tenantId is rejected.
4. A plan-limit breach returns 402 before the write.
5. Logs never include authorization, cookie, prompt, token, or apiKey.
Explain the implementation that would fail each test, then show the commands and results after the fix.
Avoid Migration Traps
Retrofitting multi-tenancy is where many teams make the same mistake: add a nullable tenant_id, ship quickly, and promise to clean it later. Temporary nullability can be part of a backfill, but the release is not complete until NOT NULL, foreign keys, tenant-aware unique constraints, indexes, RLS, and tests are all in place.
Watch five specific traps. First, separate global reference data from tenant-owned data. Country codes may be global; customer settings are not. Second, include tenant context in file storage keys, not only in database rows. Third, put tenant_id into search indexes and filters for Algolia, Meilisearch, or OpenSearch. Fourth, do not disable RLS for the admin panel. Support access should be explicit, audited impersonation. Fifth, test restore operations. If you cannot restore one tenant, an incident may force you to consider rolling back everyone.
Use phased migration prompts.
Create a migration plan for converting existing tables to multi-tenancy.
Phase 1: add tenant_id columns and create a mapping for existing records.
Phase 2: write backfill SQL and validation SQL for remaining NULL rows.
Phase 3: add NOT NULL, foreign keys, tenant-aware unique constraints, and indexes.
Phase 4: enable RLS and propose where FORCE ROW LEVEL SECURITY is needed.
Phase 5: test APIs, jobs, logs, and search indexes for tenant leaks.
Include rollback criteria and verification SQL for each phase.
Use Safe Claude Code Prompts
Your prompt to Claude Code should contain the tenant boundary. Avoid prompts such as “make this multi-tenant quickly” or “admins can see everything.” Safer prompts include explicit non-goals, test requirements, and sources.
You are implementing safe multi-tenant SaaS behavior.
Allowed areas: src/app, src/lib, db/migrations, and tests.
Requirements:
- Resolve tenant_id on the server; never trust client-provided tenant IDs.
- Use PostgreSQL RLS for every tenant-owned table.
- In pooled connections, call set_config('app.tenant_id', value, true) inside a transaction.
- Propagate tenantId through auth, billing, jobs, logs, and search indexing.
- Add tests that intentionally attempt cross-tenant reads and writes.
Forbidden:
- Disabling RLS.
- Trusting x-tenant-id.
- Logging Authorization, Cookie, API keys, or full prompts.
- Marking the task done without tests.
Final report must include changed files, commands run, failed cases found, and residual risk.
Anthropic’s Claude Code Security documentation covers permissions, prompt injection, and the user’s responsibility to review proposed changes. For sensitive SaaS repositories, keep permission settings, allowed commands, and MCP connections reviewed in source control.
CTA And Practical Handoff
Multi-tenancy is not a flashy feature, but it sells trust in B2B SaaS. If you can explain tenant data isolation, audit logs, plan limits, support access, and restore boundaries, security reviews become easier and enterprise deals move faster.
For teams adopting Claude Code, put the tenant rules in CLAUDE.md and enforce them with tests. During review, ask four questions every time: did tenant_id propagate, does RLS block mistakes, are jobs scoped, and do logs avoid sensitive data? Teams that want help building this review harness can start with Claude Code training and consulting. Individual builders can use the free cheat sheet to turn the checklist into repeatable prompts.
What Happened When We Tried This
When Masa tested this pattern on a small CRM sample, the first leak was not in the API route. It was in a daily digest worker. The UI passed tenantId, but the queue payload only contained projectId, so the worker could accidentally search outside the intended tenant. Moving the worker through withTenant and adding a SQL test for missing app.tenant_id made the bug reproducible and then fixed. The best Claude Code instruction was not “add happy-path tests”; it was “first write a test where Tenant A tries to read Tenant B.”
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.