Best Practices

AI Rules for Multi-Tenancy

AI builds single-tenant apps that cannot scale to multiple customers. Rules for tenant isolation strategies, row-level security, schema-per-tenant, shared database patterns, and tenant-aware middleware.

8 min read·March 7, 2025

One database, no tenant_id, all customer data mixed — Customer A sees Customer B data

Tenant isolation, row-level security, tenant middleware, cross-tenant prevention, per-tenant config

AI Builds Apps That Serve Only One Customer

AI generates applications with: no tenant concept (all data in one pool with no ownership), no data isolation (one customer can accidentally or intentionally see another customer data), no tenant-scoped queries (every query must manually add WHERE tenant_id = X), hardcoded single-tenant assumptions (settings, branding, and configuration are global, not per-tenant), and no tenant-aware middleware (the current tenant is not automatically resolved from the request). Adding multi-tenancy after launch requires touching every query in the application.

Modern multi-tenancy is: isolation-guaranteed (row-level security or schema separation prevents cross-tenant data access), middleware-resolved (tenant ID extracted from subdomain, header, or JWT automatically), query-scoped (every database query is automatically filtered by tenant), configuration-flexible (each tenant has custom settings, branding, and feature flags), and strategy-chosen (shared database, schema-per-tenant, or database-per-tenant based on isolation requirements). AI generates none of these.

These rules cover: tenant isolation strategies, row-level security in Postgres, schema-per-tenant vs shared database, tenant-aware middleware, cross-tenant query prevention, and per-tenant configuration.

Rule 1: Choose a Tenant Isolation Strategy

The rule: 'Select an isolation strategy based on your compliance and scale requirements. Shared database with tenant_id column: lowest cost, easiest to manage, tenant isolation enforced by application logic or RLS. Good for: SaaS with many small tenants. Schema-per-tenant: each tenant gets their own Postgres schema within one database. Moderate isolation, moderate cost. Good for: regulated industries needing stronger separation. Database-per-tenant: each tenant gets their own database. Maximum isolation, highest cost. Good for: enterprise customers with strict data residency requirements.'

For the trade-off matrix: 'Shared DB: cost = low, isolation = application-enforced, migration = one migration for all tenants, query complexity = WHERE tenant_id filter, max tenants = 10,000+. Schema-per-tenant: cost = moderate, isolation = schema boundary, migration = run per schema, query complexity = SET search_path, max tenants = 1,000. Database-per-tenant: cost = high, isolation = complete, migration = run per database, query complexity = connect to correct DB, max tenants = 100. Choose based on: tenant count, isolation requirements, and operational budget.'

AI generates: one database, no tenant_id, all data mixed. Customer A sees Customer B data. Adding tenant_id later: ALTER TABLE every table, UPDATE every row, modify every query. With tenant_id from day one: every table has it, every query filters by it, and scaling from 1 to 10,000 tenants is adding rows, not restructuring the application.

  • Shared DB + tenant_id: low cost, 10K+ tenants, application-enforced isolation
  • Schema-per-tenant: moderate isolation, 1K tenants, schema boundary separation
  • Database-per-tenant: maximum isolation, 100 tenants, complete data separation
  • Choose by: tenant count x isolation requirement x operational budget
  • Add tenant_id from day one — retrofitting multi-tenancy is a full rewrite
⚠️ Retrofitting = Full Rewrite

Adding tenant_id after launch: ALTER TABLE every table, UPDATE every row, modify every query, add middleware, add RLS. Effectively a full rewrite. With tenant_id from day one: scaling from 1 to 10,000 tenants is adding rows, not restructuring the application.

Rule 2: Row-Level Security in Postgres

The rule: 'For shared-database multi-tenancy, use Postgres Row-Level Security (RLS) to enforce tenant isolation at the database level. RLS policy: CREATE POLICY tenant_isolation ON orders FOR ALL USING (tenant_id = current_setting('app.current_tenant')::uuid). Before each request: SET app.current_tenant = 'tenant-uuid'. RLS automatically filters every SELECT, INSERT, UPDATE, and DELETE — no application code can bypass it.'

For why database-level enforcement: 'Application-level WHERE tenant_id = X is bypassable: a developer forgets the filter in one query, a new endpoint skips the middleware, or a raw query omits the condition. One missed filter = cross-tenant data exposure. RLS: the database enforces the filter on every operation. Even if the application has a bug, the database prevents cross-tenant access. The policy is the safety net that catches application-level mistakes.'

AI generates: WHERE tenant_id = req.tenantId in every query. 200 queries across 50 endpoints. One developer forgets the filter in one query: cross-tenant data leak. With RLS: the database enforces the filter. The developer cannot forget it — the policy runs automatically on every row access. One CREATE POLICY replaces 200 WHERE clauses and makes forgetting impossible.

💡 One Policy Replaces 200 WHERE Clauses

Application-level: WHERE tenant_id = X in 200 queries across 50 endpoints. One developer forgets one filter: cross-tenant data leak. Postgres RLS: CREATE POLICY runs automatically on every row access. The developer cannot forget — the database enforces it. Impossible to bypass.

Rule 3: Tenant-Aware Middleware

The rule: 'Resolve the tenant from every request automatically in middleware. Resolution strategies: subdomain (acme.rulesync.com → tenant = acme), JWT claim (token contains tenantId), custom header (X-Tenant-Id for API access), or path prefix (/api/tenants/acme/... → tenant = acme). The middleware: resolves the tenant, validates it exists and is active, sets the database context (SET app.current_tenant for RLS), and attaches the tenant to the request object for application use.'

For the middleware chain: 'Order matters: (1) authenticate the user (verify JWT/session), (2) resolve the tenant (from subdomain, JWT claim, or header), (3) authorize the user for this tenant (user belongs to this tenant), (4) set the database context (RLS or query scoping). If any step fails: 401 (not authenticated), 404 (tenant not found), or 403 (user not authorized for this tenant). The middleware chain runs before any route handler — route handlers always have a valid, authorized tenant context.'

AI generates: route handlers that manually parse req.headers['x-tenant-id'] and add it to queries. Some handlers forget. Some handlers use different header names. Some handlers do not validate the tenant exists. Middleware: one place, consistent resolution, validated, authorized, and attached to every request. Route handlers receive the tenant context — they do not need to resolve it themselves.

  • Subdomain resolution: acme.rulesync.com → tenant = acme — most common for SaaS
  • JWT claim: tenantId in the token payload — works for API-first applications
  • Middleware chain: authenticate → resolve tenant → authorize → set DB context
  • Route handlers receive tenant context — never resolve it themselves
  • Invalid tenant: 404 Not Found. User not in tenant: 403 Forbidden

Rule 4: Cross-Tenant Query Prevention

The rule: 'Prevent cross-tenant data access at multiple layers: (1) RLS at the database level (automatic filter on every query), (2) query scoping in the ORM (default scope that adds tenant_id to every query), (3) foreign key validation (referenced records must belong to the same tenant), (4) bulk operation limits (prevent SELECT * without tenant filter — full table scans across tenants), and (5) testing with multi-tenant fixtures (test that Tenant A cannot access Tenant B data).'

For ORM-level scoping: 'Create a tenant-scoped query builder: function tenantQuery(tenantId: string) { return { users: db.select().from(users).where(eq(users.tenantId, tenantId)), orders: db.select().from(orders).where(eq(orders.tenantId, tenantId)) }; }. Every query starts from tenantQuery(req.tenantId). There is no way to accidentally query without the tenant filter — the builder enforces it. Raw queries are prohibited or require explicit tenant_id.'

AI generates: db.select().from(orders).where(eq(orders.id, orderId)) — no tenant filter. If the orderId belongs to a different tenant, the data is returned. With tenant-scoped queries: db.select().from(orders).where(and(eq(orders.id, orderId), eq(orders.tenantId, tenantId))). Even if the orderId is guessed, the tenant filter prevents access. Defense in depth: RLS catches what the application misses.

ℹ️ Defense in Depth

RLS at the database level catches what the application misses. ORM-scoped queries prevent accidental cross-tenant access. Multi-tenant test fixtures verify isolation. Three layers: if one fails, the others still protect. One layer alone is a single point of failure.

Rule 5: Per-Tenant Configuration and Customization

The rule: 'Each tenant can customize: branding (logo, colors, domain), feature flags (which features are enabled for this tenant), plan limits (API rate limits, storage quotas, user count), and settings (timezone, locale, notification preferences). Store in a tenant_settings table or JSON column: { tenantId, branding: { logo, primaryColor }, features: { sso: true, apiAccess: true }, limits: { maxUsers: 50, storageGB: 100 } }. Load tenant settings on request via middleware and cache in Redis (TTL 5 minutes).'

For white-labeling: 'Advanced multi-tenancy supports white-labeling: custom domain (acme.com instead of acme.rulesync.com), custom email sender (support@acme.com), custom branding (logo, colors, favicon), and custom legal pages (terms, privacy policy). Each customization is a tenant_settings field. The application reads tenant settings and renders accordingly. For enterprise tenants, white-labeling is often the deciding factor in vendor selection.'

AI generates: global settings that apply to all customers. Customer A wants dark mode default, Customer B wants their logo in the header, Customer C needs a custom domain. Without per-tenant configuration: each request is a custom code change. With tenant_settings: each request is a configuration change in the dashboard. The application renders based on settings — no code deployment needed for tenant customization.

Complete Multi-Tenancy Rules Template

Consolidated rules for multi-tenancy.

  • Choose isolation strategy: shared DB (10K tenants), schema-per-tenant (1K), DB-per-tenant (100)
  • Row-level security in Postgres: CREATE POLICY enforces tenant filter on every query
  • Tenant middleware: resolve from subdomain/JWT/header, validate, authorize, set DB context
  • ORM-level scoping: tenant-scoped query builder, no way to forget the tenant filter
  • Cross-tenant testing: fixtures with multiple tenants, verify Tenant A cannot see Tenant B data
  • Per-tenant config: branding, features, limits, settings — cached in Redis, loaded per request
  • White-labeling for enterprise: custom domain, email, branding, legal pages
  • Add tenant_id from day one — retrofitting multi-tenancy later is a full application rewrite