Implement multi-tenant architecture with tenant isolation, data separation, and per-tenant configuration. Supports shared database and schema-per-tenant models.
Install
npx skillscat add dadbodgeoff/drift/multi-tenancy Install via the SkillsCat registry.
We need to produce a 2-3 sentence plain-text summary, objective, factual, no marketing language, no superlatives, no calls to action. Must be at most 60 words. No quotes, no markdown, no bullet points, no headings. Just plain text. We need to explain what the skill does, what problem it solves, when to use it. From description: Implement multi-tenant architecture with tenant isolation, data separation, per-tenant configuration. Supports shared database and schema-per-tenant models. Problem: need to serve multiple organizations securely, avoid data leakage, manage separate configs.
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
1. 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 │
└─────────────────────────────────────────────────────┘2. Shared Database, Schema per Tenant
┌─────────────────────────────────────────────────────┐
│ Database │
│ │
│ tenant_acme.users │
│ tenant_acme.orders │
│ tenant_globex.users │
│ tenant_globex.orders │
└─────────────────────────────────────────────────────┘3. 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