
Multitenancy is one of those architectural decisions that is nearly impossible to retrofit. If you build a single-tenant app and decide later to make it multi-tenant, you are essentially rebuilding the data model from scratch. If you build multi-tenant from day one, you get hardware utilization improvements of over 60% compared to single-tenancy — and a product that can serve thousands of customers without provisioning a separate stack for each one.
This guide covers the three isolation models, how to choose between them, and the full implementation of the most common pattern for early-stage SaaS: shared database with row-level tenant isolation.
The Three Isolation Models
Silo (separate database per tenant) — Each customer gets their own database. Maximum isolation, easiest compliance (GDPR, HIPAA), simplest queries (no tenant filtering needed). Expensive to operate at scale — provisioning, migrations, and monitoring multiply by the number of tenants.
Pool (shared database, separate schemas) — Each tenant gets their own Postgres schema (tenant_a.users, tenant_b.users). Good isolation, still manageable, migrations are slightly harder. Postgres supports this well with search_path.
Bridge (shared database, shared tables with tenant ID) — All tenants share tables, every row has a tenant_id column. Cheapest to operate, hardest to get right. Requires strict query discipline and row-level security to prevent data leaks between tenants. This is what most early-stage SaaS apps should use.
This guide implements the Bridge model — the right choice until you have compliance requirements or enterprise customers demanding dedicated infrastructure.
The Database Schema
Every table that holds tenant data needs a tenant_id foreign key. No exceptions.
-- The tenants table is the root of everything
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE, -- Used in URLs: app.com/t/acme-corp
name TEXT NOT NULL,
plan TEXT NOT NULL DEFAULT 'free',
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Users belong to tenants
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
email TEXT NOT NULL,
full_name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member', -- 'owner', 'admin', 'member'
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, email) -- Email unique per tenant, not globally
);
CREATE INDEX idx_users_tenant_id ON users(tenant_id);
-- Every business table follows the same pattern
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,
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_projects_tenant_id ON projects(tenant_id);Row-Level Security: The Safety Net
Even with disciplined application code, a bug could accidentally expose one tenant's data to another. Postgres Row Level Security (RLS) is a database-level guarantee that prevents this, regardless of what your application does.
-- Enable RLS on every tenant-scoped table
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Create a policy: only rows matching the current tenant context are visible
CREATE POLICY tenant_isolation ON users
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
-- Your app user needs to bypass RLS for admin operations
-- Create a separate superuser role for migrations only
-- The app user should NOT have BYPASSRLSBefore every query, your app sets the tenant context:
// src/lib/db.ts
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export async function withTenantContext<T>(
tenantId: string,
fn: (client: PoolClient) => Promise<T>
): Promise<T> {
const client = await pool.connect();
try {
// Set the tenant context for RLS
await client.query(
`SELECT set_config('app.current_tenant_id', $1, true)`,
[tenantId]
);
return await fn(client);
} finally {
client.release();
}
}With RLS enabled, even if a developer forgets to add WHERE tenant_id = ? to a query, Postgres enforces it at the database level. It is the difference between "we trust our developers to never make a mistake" and "we have a structural guarantee."
Tenant Resolution Middleware
Every request needs to resolve which tenant it belongs to. There are three common strategies:
Subdomain: acme.yourapp.com → tenant slug is acmePath prefix: yourapp.com/t/acme/dashboardJWT claim: tenant ID embedded in the auth token
Subdomain is the cleanest UX for B2B SaaS:
// src/middleware/tenantResolver.ts
import { db } from '../lib/db';
export async function resolveTenant(req, res, next) {
// Extract tenant from subdomain: acme.yourapp.com → 'acme'
const host = req.hostname;
const appDomain = process.env.APP_DOMAIN; // 'yourapp.com'
let tenantSlug: string | null = null;
if (host.endsWith(`.${appDomain}`)) {
tenantSlug = host.replace(`.${appDomain}`, '');
}
// Fallback: tenant slug in path /t/:slug/...
if (!tenantSlug && req.params.tenantSlug) {
tenantSlug = req.params.tenantSlug;
}
if (!tenantSlug || tenantSlug === 'www' || tenantSlug === 'api') {
return next(); // Public routes — no tenant needed
}
// Cache tenant lookups in Redis to avoid a DB hit on every request
const cacheKey = `tenant:slug:${tenantSlug}`;
let tenant = await redis.get(cacheKey).then(v => v ? JSON.parse(v) : null);
if (!tenant) {
const result = await db.query(
'SELECT id, slug, name, plan, is_active FROM tenants WHERE slug = $1',
[tenantSlug]
);
tenant = result.rows[0] || null;
if (tenant) {
// Cache for 5 minutes
await redis.setex(cacheKey, 300, JSON.stringify(tenant));
}
}
if (!tenant) {
return res.status(404).json({ error: 'Workspace not found' });
}
if (!tenant.is_active) {
return res.status(403).json({ error: 'Workspace suspended' });
}
req.tenant = tenant;
next();
}Tenant-Aware Authentication
Users belong to tenants. A user from tenant A should not be able to access tenant B, even with a valid JWT.
// src/middleware/authenticate.ts
export async function authenticate(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'No token' });
let payload;
try {
payload = verifyAccessToken(token);
} catch {
return res.status(401).json({ error: 'Invalid token' });
}
// Critical: verify the token's tenant matches the request's tenant
if (req.tenant && payload.tenantId !== req.tenant.id) {
return res.status(403).json({ error: 'Token not valid for this workspace' });
}
req.user = { id: payload.sub, role: payload.role, tenantId: payload.tenantId };
next();
}When generating tokens, embed the tenant ID:
export function generateAccessToken(user: User, tenant: Tenant) {
return jwt.sign(
{
sub: user.id,
tenantId: tenant.id,
role: user.role,
type: 'access',
},
PRIVATE_KEY,
{ algorithm: 'RS256', expiresIn: '15m' }
);
}Tenant-Scoped Queries
With RLS active, every query automatically filters by the current tenant. But you still need to set the context before querying:
// src/routes/projects.ts
router.get('/', authenticate, async (req, res) => {
const projects = await withTenantContext(req.tenant.id, async (client) => {
// RLS automatically adds WHERE tenant_id = req.tenant.id
// You don't need to add it manually — but it's good practice to anyway
const result = await client.query(
'SELECT * FROM projects ORDER BY created_at DESC'
);
return result.rows;
});
res.json(projects);
});For your own sanity, always add WHERE tenant_id = $1 explicitly in your queries even though RLS enforces it. It makes the tenant scoping visible in code reviews and protects you if RLS is ever accidentally disabled.
Plan-Based Feature Flags
Multi-tenant SaaS usually means multiple plans. Keep plan enforcement in one place:
// src/lib/planLimits.ts
const PLAN_LIMITS = {
free: {
maxProjects: 3,
maxMembers: 5,
fileStorageMB: 100,
apiCallsPerDay: 1000,
},
pro: {
maxProjects: 50,
maxMembers: 25,
fileStorageMB: 10000,
apiCallsPerDay: 50000,
},
enterprise: {
maxProjects: Infinity,
maxMembers: Infinity,
fileStorageMB: Infinity,
apiCallsPerDay: Infinity,
},
} as const;
export async function checkPlanLimit(
tenantId: string,
plan: string,
resource: keyof typeof PLAN_LIMITS.free
) {
const limit = PLAN_LIMITS[plan]?.[resource] ?? 0;
if (limit === Infinity) return; // Enterprise — no limits
const current = await getCurrentUsage(tenantId, resource);
if (current >= limit) {
throw new PlanLimitError(
`${resource} limit reached for ${plan} plan (${limit}). Upgrade to continue.`
);
}
}// In your route handler
router.post('/projects', authenticate, async (req, res) => {
try {
await checkPlanLimit(req.tenant.id, req.tenant.plan, 'maxProjects');
} catch (err) {
if (err instanceof PlanLimitError) {
return res.status(403).json({ error: err.message, upgradeRequired: true });
}
throw err;
}
const project = await createProject(req.tenant.id, req.user.id, req.body);
res.status(201).json(project);
});Tenant Provisioning
When a new customer signs up, you create the tenant and the first user (the owner) atomically:
async function provisionTenant(data: {
tenantName: string;
ownerEmail: string;
ownerName: string;
password: string;
}) {
return await db.transaction(async (tx) => {
// Create tenant
const slug = slugify(data.tenantName) + '-' + randomBytes(3).toString('hex');
const [tenant] = await tx
.insert(tenants)
.values({ name: data.tenantName, slug })
.returning();
// Create owner
const passwordHash = await bcrypt.hash(data.password, 12);
const [owner] = await tx
.insert(users)
.values({
tenantId: tenant.id,
email: data.ownerEmail,
fullName: data.ownerName,
passwordHash,
role: 'owner',
})
.returning();
// Queue welcome email
await emailQueue.add('welcome', {
userId: owner.id,
tenantId: tenant.id,
tenantSlug: tenant.slug,
});
return { tenant, owner };
});
}The Mistakes That Cost the Most
Forgetting tenant_id on a table. One table without tenant scoping becomes a data leak waiting to happen. Audit every table at schema review time.
Not enabling RLS. Application-level tenant filtering is necessary but not sufficient. One missing WHERE clause in one query leaks data. RLS is the structural guarantee.
Caching data without tenant scope. cache:users:all across your entire app is a cross-tenant data leak. Every cache key must include the tenant ID: cache:${tenantId}:users.
Global rate limits instead of per-tenant limits. One tenant hammering your API should not affect another tenant's experience. Rate limit by tenantId, not just by IP.
Comments (0)
Login to post a comment.