← Back to journal
·4 min read

Designing Multi-Tenant Data Isolation That Actually Works

ArchitectureMulti-tenantSaaSDatabase Design

Multi-tenancy sounds simple until you are three months into a production system and realize that a bug in your tenant filtering just leaked one customer's data to another. I have designed multi-tenant systems from scratch and inherited ones that were broken in subtle ways. Here is what I have learned about the three main isolation strategies and when each one makes sense.

The Three Approaches

Shared database, shared schema - Every tenant's data lives in the same tables, separated by a tenant_id column. Simplest to build, hardest to keep secure.

Shared database, separate schemas - Each tenant gets their own schema within the same database instance. Good middle ground between isolation and resource efficiency.

Separate databases - Each tenant gets their own database. Maximum isolation, maximum operational overhead.

When to Use Which

For most SaaS products serving SMBs, I recommend starting with shared schema and row-level filtering. It keeps your infrastructure simple, your migrations unified, and your costs predictable. You can always migrate high-value tenants to dedicated schemas or databases later.

Separate schemas make sense when tenants need genuinely different data models, or when you have regulatory requirements that demand stronger isolation (healthcare, finance, government).

Separate databases are for enterprise clients who demand it contractually or when data residency laws require geographic isolation. The operational cost is real - every migration, every index change, every backup strategy multiplies by your tenant count.

Row-Level Security Done Right

If you go the shared-schema route, every query must be tenant-scoped. This is where most teams make mistakes. Relying on application-level filtering alone means one missed WHERE clause away from a data breach.

Here is how I enforce it at the database level with PostgreSQL row-level security:

-- Enable RLS on the table
ALTER TABLE conversations ENABLE ROW LEVEL SECURITY;

-- Create a policy that filters by tenant
CREATE POLICY tenant_isolation ON conversations
    USING (tenant_id = current_setting('app.current_tenant')::uuid);

-- In your application, set the tenant context per request
SET app.current_tenant = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';

Even if application code forgets the tenant filter, the database will enforce it. Defense in depth.

In Go, I wrap this into middleware that sets the tenant context at the start of every request:

func TenantMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        tenantID := r.Header.Get("X-Tenant-ID")
        if tenantID == "" {
            http.Error(w, "missing tenant", http.StatusBadRequest)
            return
        }
        ctx := context.WithValue(r.Context(), tenantKey, tenantID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Session Management Across Tenants

Users who belong to multiple tenants (think a consultant managing several accounts) need a clean tenant-switching experience. The pattern I use: authenticate the user once, then let them select an active tenant. The active tenant ID travels with every request, and the backend validates that the user actually has access to that tenant before processing anything.

Never trust the tenant ID from the client alone. Always verify membership server-side.

Webhook Delivery Per Tenant

Each tenant needs their own webhook endpoints, retry policies, and delivery logs. I have seen systems where a single tenant's failing webhook endpoint causes a backlog that delays delivery to every other tenant. The fix: per-tenant delivery queues. If one tenant's endpoint is down, it only affects their queue. Everyone else keeps getting webhooks on time.

The Mistake That Costs the Most

The most expensive mistake is not choosing the wrong isolation strategy. It is choosing one and then being inconsistent about enforcement. If you do row-level security, every table needs it, including junction tables, audit logs, and analytics tables. If you do schema-per-tenant, every new feature migration needs to run across all schemas.

Pick a strategy, enforce it everywhere, and automate the enforcement. Write a linter that flags any raw SQL query missing a tenant filter. Add integration tests that verify tenant isolation. Make it impossible to accidentally cross tenant boundaries, not just unlikely.