Multi-Tenancy
Build SaaS apps that serve multiple organizations securely.
When to Use This Skill
-
B2B SaaS applications
-
White-label platforms
-
Enterprise software
-
Any app serving multiple organizations
Isolation Models
- Shared Database, Shared Schema (Recommended for most)
┌─────────────────────────────────────────────────────┐ │ Database │ │ │ │ users: id, tenant_id, email, ... │ │ orders: id, tenant_id, user_id, ... │ │ products: id, tenant_id, name, ... │ │ │ │ All tables have tenant_id column │ └─────────────────────────────────────────────────────┘
- Shared Database, Schema per Tenant
┌─────────────────────────────────────────────────────┐ │ Database │ │ │ │ tenant_acme.users │ │ tenant_acme.orders │ │ tenant_globex.users │ │ tenant_globex.orders │ └─────────────────────────────────────────────────────┘
- Database per Tenant (Enterprise)
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ acme_db │ │ globex_db │ │ initech_db │ │ │ │ │ │ │ │ users │ │ users │ │ users │ │ orders │ │ orders │ │ orders │ └──────────────┘ └──────────────┘ └──────────────┘
TypeScript Implementation
Tenant Context
// tenant-context.ts import { AsyncLocalStorage } from 'async_hooks';
interface TenantContext { tenantId: string; tenantSlug: string; plan: 'free' | 'pro' | 'enterprise'; features: string[]; }
const tenantStorage = new AsyncLocalStorage<TenantContext>();
export function getTenant(): TenantContext { const tenant = tenantStorage.getStore(); if (!tenant) { throw new Error('No tenant context available'); } return tenant; }
export function runWithTenant<T>(tenant: TenantContext, fn: () => T): T { return tenantStorage.run(tenant, fn); }
export { tenantStorage, TenantContext };
Tenant Middleware
// tenant-middleware.ts import { Request, Response, NextFunction } from 'express'; import { runWithTenant, TenantContext } from './tenant-context';
interface TenantMiddlewareOptions { headerName?: string; subdomainExtract?: boolean; pathExtract?: boolean; }
export function tenantMiddleware(options: TenantMiddlewareOptions = {}) { const { headerName = 'x-tenant-id', subdomainExtract = true } = options;
return async (req: Request, res: Response, next: NextFunction) => { let tenantId: string | undefined;
// Strategy 1: Header
tenantId = req.headers[headerName.toLowerCase()] as string;
// Strategy 2: Subdomain (acme.yourapp.com)
if (!tenantId && subdomainExtract) {
const host = req.hostname;
const subdomain = host.split('.')[0];
if (subdomain && subdomain !== 'www' && subdomain !== 'app') {
tenantId = subdomain;
}
}
// Strategy 3: Path (/t/acme/dashboard)
if (!tenantId && options.pathExtract) {
const match = req.path.match(/^\/t\/([^/]+)/);
if (match) {
tenantId = match[1];
}
}
// Strategy 4: User's default tenant (from JWT)
if (!tenantId && req.user?.defaultTenantId) {
tenantId = req.user.defaultTenantId;
}
if (!tenantId) {
return res.status(400).json({ error: 'Tenant not specified' });
}
// Load tenant from database
const tenant = await db.tenants.findUnique({
where: { id: tenantId },
select: { id: true, slug: true, plan: true, features: true },
});
if (!tenant) {
return res.status(404).json({ error: 'Tenant not found' });
}
// Check user has access to tenant
if (req.user) {
const membership = await db.tenantMemberships.findFirst({
where: { userId: req.user.id, tenantId: tenant.id },
});
if (!membership) {
return res.status(403).json({ error: 'Access denied to tenant' });
}
req.userRole = membership.role;
}
// Run request with tenant context
runWithTenant(
{
tenantId: tenant.id,
tenantSlug: tenant.slug,
plan: tenant.plan,
features: tenant.features,
},
() => next()
);
}; }
Tenant-Scoped Queries
// tenant-prisma.ts import { PrismaClient } from '@prisma/client'; import { getTenant } from './tenant-context';
// Extend Prisma with automatic tenant filtering export function createTenantPrisma(prisma: PrismaClient) { return prisma.$extends({ query: { $allModels: { async findMany({ model, operation, args, query }) { // Auto-add tenant filter args.where = { ...args.where, tenantId: getTenant().tenantId }; return query(args); }, async findFirst({ model, operation, args, query }) { args.where = { ...args.where, tenantId: getTenant().tenantId }; return query(args); }, async findUnique({ model, operation, args, query }) { // For unique queries, verify tenant after fetch const result = await query(args); if (result && result.tenantId !== getTenant().tenantId) { return null; // Hide cross-tenant data } return result; }, async create({ model, operation, args, query }) { // Auto-set tenant on create args.data = { ...args.data, tenantId: getTenant().tenantId }; return query(args); }, async update({ model, operation, args, query }) { // Ensure update is scoped to tenant args.where = { ...args.where, tenantId: getTenant().tenantId }; return query(args); }, async delete({ model, operation, args, query }) { args.where = { ...args.where, tenantId: getTenant().tenantId }; return query(args); }, }, }, }); }
// Usage const tenantDb = createTenantPrisma(prisma);
// These are automatically scoped to current tenant const users = await tenantDb.user.findMany(); const order = await tenantDb.order.create({ data: { ... } });
Per-Tenant Configuration
// tenant-config.ts interface TenantConfig { branding: { logo?: string; primaryColor?: string; companyName?: string; }; features: { maxUsers: number; maxStorage: number; apiAccess: boolean; sso: boolean; }; integrations: { slack?: { webhookUrl: string }; stripe?: { customerId: string }; }; }
class TenantConfigService { private cache = new Map<string, TenantConfig>();
async getConfig(tenantId: string): Promise<TenantConfig> { if (this.cache.has(tenantId)) { return this.cache.get(tenantId)!; }
const tenant = await db.tenants.findUnique({
where: { id: tenantId },
include: { config: true },
});
const config = this.buildConfig(tenant);
this.cache.set(tenantId, config);
return config;
}
private buildConfig(tenant: Tenant): TenantConfig { // Merge plan defaults with tenant overrides const planDefaults = PLAN_CONFIGS[tenant.plan]; return { branding: { ...tenant.config?.branding }, features: { ...planDefaults.features, ...tenant.config?.features }, integrations: { ...tenant.config?.integrations }, }; }
invalidateCache(tenantId: string) { this.cache.delete(tenantId); } }
Database Schema
-- Tenants table CREATE TABLE tenants ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), slug VARCHAR(50) UNIQUE NOT NULL, name VARCHAR(255) NOT NULL, plan VARCHAR(50) DEFAULT 'free', features TEXT[] DEFAULT '{}', config JSONB DEFAULT '{}', created_at TIMESTAMP DEFAULT NOW() );
-- Users belong to tenants via memberships CREATE TABLE tenant_memberships ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES users(id) ON DELETE CASCADE, tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, role VARCHAR(50) DEFAULT 'member', created_at TIMESTAMP DEFAULT NOW(), UNIQUE(user_id, tenant_id) );
-- All data tables have tenant_id CREATE TABLE orders ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES tenants(id), user_id UUID REFERENCES users(id), -- ... other columns created_at TIMESTAMP DEFAULT NOW() );
-- Index for tenant queries CREATE INDEX idx_orders_tenant ON orders(tenant_id);
-- Row Level Security (optional, extra protection) ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders USING (tenant_id = current_setting('app.current_tenant')::uuid);
Python Implementation
tenant_context.py
from contextvars import ContextVar from dataclasses import dataclass from typing import Optional
@dataclass class TenantContext: tenant_id: str tenant_slug: str plan: str features: list[str]
_tenant_context: ContextVar[Optional[TenantContext]] = ContextVar( "tenant_context", default=None )
def get_tenant() -> TenantContext: tenant = _tenant_context.get() if not tenant: raise RuntimeError("No tenant context") return tenant
def set_tenant(tenant: TenantContext): return _tenant_context.set(tenant)
FastAPI Middleware
tenant_middleware.py
from fastapi import Request, HTTPException from starlette.middleware.base import BaseHTTPMiddleware
class TenantMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): tenant_id = request.headers.get("x-tenant-id")
if not tenant_id:
# Try subdomain
host = request.headers.get("host", "")
subdomain = host.split(".")[0]
if subdomain not in ["www", "app", "api"]:
tenant_id = subdomain
if not tenant_id:
raise HTTPException(400, "Tenant not specified")
tenant = await db.tenants.find_unique(where={"id": tenant_id})
if not tenant:
raise HTTPException(404, "Tenant not found")
token = set_tenant(TenantContext(
tenant_id=tenant.id,
tenant_slug=tenant.slug,
plan=tenant.plan,
features=tenant.features,
))
try:
response = await call_next(request)
return response
finally:
_tenant_context.reset(token)
Best Practices
-
Always filter by tenant_id - Never trust client-provided IDs alone
-
Use middleware - Centralize tenant resolution
-
Index tenant_id - Every tenant-scoped table needs this index
-
Consider RLS - Extra protection layer in PostgreSQL
-
Cache tenant config - Avoid repeated lookups
Common Mistakes
-
Forgetting tenant filter on queries (data leak!)
-
Not validating user's tenant access
-
Hardcoding tenant-specific logic
-
No index on tenant_id columns
-
Allowing cross-tenant references