Architecture No. 324

Multi-Tenancy skill for Claude Code

No leaks between customers, no noisy neighbors. Claude picks a tenant isolation model per pricing tier, resolves the tenant once from a trusted claim, and carries that context through the database session, jobs, events, caches, storage and logs. Isolation is enforced by row-level security or physical separation that fails closed, noisy tenants are contained with quotas and fair queues, and every endpoint gets a cross-tenant leak test.

  • namemulti-tenancy
  • size185 lines · ≈2,150 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Designs and audits multi-tenant SaaS architecture by choosing an isolation model (shared tables with tenant_id, schema per tenant, database or stack per tenant, or tiered hybrids), resolving and propagating tenant context from request to database, cache, queue, logs, and storage, enforcing isolation with Postgres row-level security, and containing noisy neighbors with quotas, fair queuing, and per-tenant limits. Covers partitioning, tenant lifecycle (onboarding, export, deletion), and cross-tenant leak tests. Use when building or reviewing a SaaS backend, adding a tenant_id, worried about data leaks between customers, or when one large customer degrades service for others.

Try asking

  • We're building a B2B SaaS. Should we use a tenant_id column or a schema per customer?
  • Audit this codebase for places where one tenant could see another tenant's data
  • One big customer's exports slow everyone down. How do we isolate them?

Use it when

  • Designing or reviewing a SaaS backend serving many customers
  • Adding tenant_id and isolation to an existing single-tenant app
  • Worried about cross-customer data leaks
  • A large tenant degrades performance for others

Skip it when

How to check the result

Cross-tenant tests pass for every endpoint and job, queries with no tenant set return zero rows, and one tenant at 10x load leaves other tenants within SLO.

The common mistake it prevents

Setting the tenant with a session-level SET on a pooled connection, so the next request on that connection silently runs as the previous tenant.

~/.claude/skills/multi-tenancy/SKILL.md

--- name: multi-tenancy description: Designs and audits multi-tenant SaaS architecture by choosing an isolation model (shared tables with tenant_id, schema per tenant, database… license: MIT ---

A multi-tenant system is correct when no code path can read or write another tenant’s data even if a developer forgets a WHERE clause, and fair when one tenant’s load cannot take down the others. Both properties must be enforced by infrastructure, not by reviewers remembering.

Ground rules

  • Resolve the tenant once, at the edge, from a trusted source (verified token claim, mapped domain), never from a client-supplied header or body field alone.
  • Propagate tenant context implicitly through the request and explicitly through every async boundary (jobs, events, scheduled tasks).
  • Enforce isolation in a layer developers cannot skip: row-level security, separate schemas/databases, or a mandatory repository filter that fails closed.
  • Every tenant-owned table has tenant_id NOT NULL, and every index used by queries leads with it.
  • Write automated cross-tenant tests for every endpoint and job.

Isolation models

ModelIsolationCost per tenantOps complexityFits
Pool: shared tables + tenant_idLogical (RLS)LowestLow; one schema to migrateMany small tenants (thousands+), self-serve SaaS
Bridge: schema per tenantStronger logicalLow–mediumMigrations x N schemas; catalog bloat past ~1k schemasTens to hundreds of mid-size tenants
Silo: database per tenantPhysicalHighFleet management, per-DB migrations, connection countsEnterprise, data residency, per-tenant restore
Stack per tenantFullHighestFull deployment per tenantRegulated or very large customers
Tiered hybridPer tierMatches revenueTwo code paths for routingFree/pro in pool, enterprise in silo

Default for a new SaaS: pool model with RLS, designed so a tenant can later be moved to a dedicated database (tenant-aware routing from day one).

Workflow

- [ ] 1. Choose isolation model per tier (table above)
- [ ] 2. Define tenant resolution at the edge
- [ ] 3. Propagate context: request, DB session, jobs, events, logs, cache, storage
- [ ] 4. Enforce isolation (RLS or physical separation); fail closed
- [ ] 5. Schema: tenant_id columns, composite keys and indexes, partitioning
- [ ] 6. Noisy-neighbor controls
- [ ] 7. Tenant lifecycle: provision, migrate, export, delete, move
- [ ] 8. Cross-tenant leak tests in CI

2–3. Resolve and propagate (Node example)

import { AsyncLocalStorage } from 'node:async_hooks';

type TenantCtx = { tenantId: string; plan: 'free' | 'pro' | 'enterprise' };
export const tenantStore = new AsyncLocalStorage<TenantCtx>();

export function tenantMiddleware(req, res, next) {
  const claims = req.auth;                                // verified JWT/session, not a raw header
  if (!claims?.tenantId) return res.status(401).end();
  tenantStore.run({ tenantId: claims.tenantId, plan: claims.plan }, next);
}

export function currentTenant(): TenantCtx {
  const ctx = tenantStore.getStore();
  if (!ctx) throw new Error('tenant context missing');    // fail closed
  return ctx;
}

Python: contextvars.ContextVar set in middleware. JVM: request-scoped bean or ThreadLocal/ScopedValue cleared in a finally.

Propagation checklist:

BoundaryHow
DatabaseSet tenant per transaction (set_config('app.tenant_id', $1, true))
Background jobstenantId in every job payload; worker re-establishes context before running
EventstenantId in the envelope; consumers set context
CacheKey prefix t:{tenantId}:...; never cache cross-tenant results under shared keys
Object storagePrefix tenants/{tenantId}/ plus IAM/policy scoping where possible
Search indexMandatory tenant filter, or index per tenant for silo tiers
Logs, traces, metricstenant_id attribute on every log line and span; metric labels only if cardinality allows
Admin / support toolsExplicit, audited tenant switching; no “all tenants” query by default

4. Row-level security (Postgres, pool model)

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;          -- applies to the table owner too

CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid)
  WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid);
await db.transaction(async (tx) => {
  await tx.query("SELECT set_config('app.tenant_id', $1, true)", [currentTenant().tenantId]);
  return tx.query('SELECT * FROM invoices WHERE status = $1', ['open']);   // RLS filters by tenant
});
  • The app connects as a role without BYPASSRLS and not as superuser.
  • set_config(..., true) is transaction-local, which is safe with PgBouncer transaction pooling; session-level SET leaks the tenant to the next client of that connection.
  • With current_setting(..., true) a never-set variable yields NULL, and after a transaction-local set it reverts to an empty string; NULLIF(..., '') turns both into NULL, so queries return nothing rather than everything or a cast error (fails closed).
  • Policy details, performance, and testing are covered in row-level-security.

5. Schema and partitioning

  • Composite primary/foreign keys or unique constraints including tenant_id (UNIQUE (tenant_id, email)), so references cannot cross tenants.
  • Indexes lead with tenant_id: (tenant_id, created_at DESC).
  • Large pool tables: Postgres declarative partitioning by HASH (tenant_id) or LIST for big tenants; or a distributed Postgres (e.g., Citus) sharded on tenant_id.
  • Keep a tenants registry: id, plan, region, isolation tier, DB/shard location, status. Routing reads it (cached).

6. Noisy neighbors

ResourceControl
APIPer-tenant rate limits and quotas by plan (see rate-limiting)
Databasestatement_timeout, per-tenant query budgets, separate pools or replicas for heavy tenants, read replicas for reporting
QueuesPer-tenant queues or fair scheduling (round-robin across tenants), concurrency cap per tenant
StorageQuotas enforced at upload time
Expensive features (exports, AI calls)Async with per-tenant concurrency limits
OutliersMove to a dedicated database or stack (tier upgrade)

Measure per-tenant resource usage (requests, DB time, queue time, storage) so you can see who is noisy before customers complain.

7. Lifecycle

  • Provisioning: idempotent; creates registry row, defaults, and (for bridge/silo) schema or database via automation.
  • Migrations: pool = one migration; bridge/silo = migrate every tenant with progress tracking, retries, and the app tolerating mixed versions during rollout.
  • Export: per-tenant export job (customer data portability).
  • Deletion: hard-delete or crypto-shred all tenant data, including backups policy, caches, search indexes, object storage, and analytics copies. Document the timeline.
  • Move tenant (pool → silo, region change): copy, dual-write or freeze window, verify counts, switch routing in the registry.
  • Per-tenant restore is hard in the pool model; decide early whether it is a contractual requirement.

8. Cross-tenant tests

it.each(endpoints)('%s does not leak across tenants', async (ep) => {
  const a = await createTenantWithData();
  const b = await createTenantWithData();
  const res = await ep.call({ as: a.user, targetId: b.resourceId });
  expect([403, 404]).toContain(res.status);
});

Also test: jobs enqueued by tenant A run with A’s context only; cache keys differ per tenant; RLS blocks a raw query with no tenant set.

Gotchas

  • Tenant ID taken from a URL path or header without checking the authenticated user belongs to that tenant is an IDOR vulnerability.
  • Connection pools that set session variables leak tenant context between requests.
  • Background jobs and cron tasks are where tenant filters are most often forgotten.
  • Global unique constraints (email unique across all tenants) may be wrong for your product, or exactly right; decide explicitly.
  • Analytics and data warehouse copies need the same tenant boundary and deletion handling as production.
  • Metrics labeled by tenant ID can explode cardinality; aggregate or sample.

Verification

  1. Cross-tenant tests pass for every endpoint and job.
  2. With no tenant context set, a query on a tenant table returns zero rows (not all).
  3. Load test with one tenant at 10x normal traffic: other tenants’ p95 stays within SLO.
  4. Delete a test tenant and confirm nothing remains in DB, cache, storage, or search.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc