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.
- name
multi-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
- You only need to write and test Postgres RLS policies: use row-level-security
- You need per-client API throttling algorithms: use rate-limiting
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.
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
| Model | Isolation | Cost per tenant | Ops complexity | Fits |
|---|---|---|---|---|
Pool: shared tables + tenant_id | Logical (RLS) | Lowest | Low; one schema to migrate | Many small tenants (thousands+), self-serve SaaS |
| Bridge: schema per tenant | Stronger logical | Low–medium | Migrations x N schemas; catalog bloat past ~1k schemas | Tens to hundreds of mid-size tenants |
| Silo: database per tenant | Physical | High | Fleet management, per-DB migrations, connection counts | Enterprise, data residency, per-tenant restore |
| Stack per tenant | Full | Highest | Full deployment per tenant | Regulated or very large customers |
| Tiered hybrid | Per tier | Matches revenue | Two code paths for routing | Free/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:
| Boundary | How |
|---|---|
| Database | Set tenant per transaction (set_config('app.tenant_id', $1, true)) |
| Background jobs | tenantId in every job payload; worker re-establishes context before running |
| Events | tenantId in the envelope; consumers set context |
| Cache | Key prefix t:{tenantId}:...; never cache cross-tenant results under shared keys |
| Object storage | Prefix tenants/{tenantId}/ plus IAM/policy scoping where possible |
| Search index | Mandatory tenant filter, or index per tenant for silo tiers |
| Logs, traces, metrics | tenant_id attribute on every log line and span; metric labels only if cardinality allows |
| Admin / support tools | Explicit, 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
BYPASSRLSand not as superuser. set_config(..., true)is transaction-local, which is safe with PgBouncer transaction pooling; session-levelSETleaks 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)orLISTfor big tenants; or a distributed Postgres (e.g., Citus) sharded ontenant_id. - Keep a
tenantsregistry: id, plan, region, isolation tier, DB/shard location, status. Routing reads it (cached).
6. Noisy neighbors
| Resource | Control |
|---|---|
| API | Per-tenant rate limits and quotas by plan (see rate-limiting) |
| Database | statement_timeout, per-tenant query budgets, separate pools or replicas for heavy tenants, read replicas for reporting |
| Queues | Per-tenant queues or fair scheduling (round-robin across tenants), concurrency cap per tenant |
| Storage | Quotas enforced at upload time |
| Expensive features (exports, AI calls) | Async with per-tenant concurrency limits |
| Outliers | Move 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
- Cross-tenant tests pass for every endpoint and job.
- With no tenant context set, a query on a tenant table returns zero rows (not all).
- Load test with one tenant at 10x normal traffic: other tenants’ p95 stays within SLO.
- 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.