Tenant Isolation Is a Data Model, Not a WHERE Clause

Aug 24

The most common multi-tenant incident is not a broken cipher. It is a valid query executed with the wrong tenant context.

That is why WHERE tenant_id = ? is not an isolation architecture. It is one predicate that every code path, migration, background worker, cache key, search index, export, and support tool must remember forever.

Tenant isolation is the system-wide guarantee that one tenant cannot consume, infer, modify, or starve another tenant’s resources beyond an explicitly approved boundary.

Choose a tenancy model per resource

“Are we pooled or siloed?” is usually the wrong binary question. A SaaS product can pool stateless compute, silo encryption keys, partition queues, and offer dedicated databases to regulated customers.

Use three broad models:

ModelShapeStrengthCost
SiloDedicated resource per tenantClear blast radius and customizationOperational multiplication
BridgeShared service, partitioned resource groupsTunable isolationMore control-plane complexity
PoolShared tables and infrastructureEfficient utilizationStrongest need for policy enforcement

AWS’s SaaS isolation guidance emphasizes that authentication and isolation are different concerns: a user can be correctly authenticated and still access the wrong tenant’s resource if authorization context is not enforced at every boundary.

Make the choice per resource with explicit drivers:

  • regulatory boundary;
  • restore and deletion requirements;
  • noisy-neighbor tolerance;
  • tenant size distribution;
  • customization needs;
  • unit economics;
  • operational maturity.

Siloing every resource maximizes conceptual clarity but can make patching, migrations, and observability unmanageable. Pooling everything minimizes infrastructure count but makes a single context bug catastrophic. Most serious systems use tiers.

Tenant identity is request state

Tenant identity should be resolved once from authenticated authority, then propagated as typed context. Do not accept a tenant ID from a request body and trust it because it matches a UUID shape.

credential -> principal -> allowed tenant memberships -> selected tenant context

The context should include more than an ID:

{
  "tenantId": "t_83f4",
  "principalId": "u_1209",
  "roles": ["billing_reader"],
  "plan": "enterprise",
  "region": "ap-south-1",
  "policyVersion": 17
}

Sign or derive it inside a trusted boundary. Propagate the minimum needed to downstream services. Log it on every decision, but never let logging become a source of cross-tenant data leakage.

Background work must carry the same context. “System job” is not a tenant. A job should declare which tenant it acts for, which authority created it, and what scope was granted.

Put a policy below application code

For pooled PostgreSQL tables, Row-Level Security (RLS) can make the database enforce tenant predicates:

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_invoices ON invoices
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);

Set tenant context within the transaction:

BEGIN;
SET LOCAL app.tenant_id = '7f35...';
SELECT * FROM invoices WHERE status = 'overdue';
COMMIT;

PostgreSQL applies a default-deny policy when RLS is enabled and no applicable policy exists. But there are sharp edges:

  • table owners normally bypass RLS unless forced;
  • superusers and roles with BYPASSRLS bypass policies;
  • connection pools can leak session state if context is not transaction-local and reliably reset;
  • maintenance and migration roles need deliberate scope;
  • security-definer functions can cross policy boundaries;
  • referential integrity checks have special behavior and must be reviewed.

RLS is defense in depth, not permission to stop filtering in the application. Application predicates improve clarity and query plans; RLS protects forgotten paths.

Make identifiers and caches tenant-safe by construction

Prefer globally unique external identifiers, but still include tenant scope in authorization. Knowing an opaque invoice ID must not grant access.

Every derived namespace needs tenant identity:

cache:   tenant/{tenantId}/invoice/{invoiceId}
object:  tenant/{tenantId}/exports/{exportId}.csv
search:  filter tenant_id before ranking and aggregation
queue:   partition or quota by tenant
metric:  avoid raw tenant cardinality in every time series

A cache key missing tenant ID can bypass perfect database isolation. An object-store pre-signed URL can outlive revoked membership. A vector search can leak semantic fragments through nearest-neighbor results. Review the entire data path, not only the primary database.

Isolate capacity as well as rows

Security isolation asks, “Can tenant A see tenant B?” Performance isolation asks, “Can tenant A make tenant B unavailable?”

Controls include:

  • per-tenant concurrency and rate budgets;
  • weighted fair queues;
  • query-cost limits and statement timeouts;
  • separate worker pools for bulk exports;
  • connection budgets by workload class;
  • storage and egress quotas;
  • promotion of large tenants to dedicated partitions.

Measure tenant concentration. A pooled design where one customer creates 60% of database load is operationally a single-tenant dependency wearing a multi-tenant label.

Avoid putting unbounded tenant IDs into metric labels. Keep low-cardinality service metrics, then send tenant-attributed events to logs or analytics designed for high-cardinality investigation.

Design restore, migration, and deletion first

Pooling changes operational promises. Restoring one tenant from a physical database backup may require a logical extraction and reconciliation process. A tenant deletion must cover replicas, search indexes, caches, object storage, analytics, and retained events.

Before choosing pooled storage, demonstrate:

  1. tenant-scoped export;
  2. tenant-scoped logical restore;
  3. verifiable deletion with retention exceptions;
  4. online schema migration across all tiers;
  5. promotion from pooled to dedicated storage;
  6. reconciliation after partial failure.

If enterprise contracts promise tenant restore but the architecture only supports cluster restore, the product has sold a capability the platform cannot perform.

Test isolation as a negative property

Happy-path tests prove users can access their data. Isolation tests must prove all the ways they cannot access someone else’s.

Create two tenants and systematically attempt:

  • direct object ID substitution;
  • missing tenant context;
  • stale membership tokens;
  • background job replay under another tenant;
  • cache-key collisions;
  • export and search aggregation leakage;
  • elevated support-role misuse;
  • connection reuse after a previous tenant transaction.

Run these tests against production-like pooling and roles. An application test using a database owner account cannot validate RLS behavior honestly.

CTO review questions

  1. Where is tenant identity established and where can it be overwritten?
  2. Which resources are pooled, bridged, or siloed, and why?
  3. What policy still protects data when an application predicate is forgotten?
  4. Can a connection pool retain another tenant’s session state?
  5. How are caches, search, files, events, and analytics scoped?
  6. Can one tenant exhaust shared capacity?
  7. Can we restore or delete exactly one tenant?

Multi-tenancy is not a table-layout choice. It is an identity, policy, capacity, and operations model. The right design makes tenant context unavoidable and makes violations difficult to express—not merely easy to catch in code review.

References

>