Back to Engineering
Engineering Case Study

Multi-Tenant Isolation Architecture for a SaaS Platform

How tenant boundaries are designed to survive a compromised neighbor: pooled infrastructure with enforced isolation, scoped keys, and contained blast radius.

PostgreSQL RLSKubernetes namespacesKMS envelope encryptionOPA/RegoSPIFFE/SPIRE mTLSSTS-issued tokensTamper-evident audit logs
PyramidLedger Engineering7 min read

Choosing a tenancy model

Multi-tenant SaaS architecture comes down to three canonical shapes: fully siloed infrastructure per tenant, a pooled model where tenants share compute and storage behind logical boundaries, and a hybrid pool-of-shards model that groups tenants into isolated cells. The instinct is to pick based on scale or cost, but the more useful lens is threat model. The real question is: if one tenant's credentials are fully compromised, what can an attacker reach? In a naive pooled design, the answer is often "every other tenant's data," because isolation lives entirely in application code — a missing WHERE clause, a forgotten scope, a bug in a background job — and application code is exactly what gets compromised.

A silo-per-tenant model solves this cleanly but doesn't scale operationally: patching, deploying, and monitoring N independent stacks is a linear cost that grows with customer count, not with load. In practice we default to a pooled model with isolation enforced below the application layer, and reserve dedicated cells for tenants whose compliance posture genuinely requires physical separation — regulated data residency requirements, or contractual demands for a dedicated key hierarchy. The pooled default has to earn that position by making cross-tenant access structurally hard, not just unlikely.

The data layer: isolation as a database-enforced property

Application-level tenant scoping — adding a tenant_id filter to every query — is necessary but insufficient on its own, because it depends on every engineer, every ORM query, and every ad-hoc script getting it right every time. The backstop is database-enforced row-level security: PostgreSQL RLS policies bound to the connection role, so that even a query with no WHERE clause at all cannot return rows outside the caller's tenant. The database, not the application, becomes the last line of defense. This has real operational cost — RLS predicates interact with the query planner, and tenant_id has to be the leading column in composite indexes or the planner will happily table-scan through every tenant's rows before applying the filter.

Encryption follows the same principle of not trusting a single layer. Rather than one database-wide encryption key, each tenant's data is protected by its own data-encryption key, itself wrapped by a tenant-scoped key-encryption key held in a KMS or HSM. This buys two properties that matter more than the encryption itself: tenant offboarding becomes cryptographic key destruction rather than a best-effort DELETE that might miss a replica or a backup, and a compromised DEK for one tenant is useless against any other tenant's data. Schema-per-tenant is the other common pattern and it does give stronger native isolation, but it trades that for migration and connection-pooling overhead that becomes painful well before four-figure tenant counts — we treat it as the right answer only for the dedicated-cell tenants, not the pooled default.

Compute and network isolation

Below the data layer, pooled compute needs the same defense-in-depth treatment. In Kubernetes, tenant workloads or tenant-tier services sit in namespaces with default-deny network policies, so a pod can only reach what it's explicitly allowed to reach — not the whole cluster by default. East-west traffic between services carries workload identity via mTLS (SPIFFE/SPIRE-issued certificates are a clean way to do this), so a service can cryptographically assert not just "I am the billing service" but "I am acting on behalf of tenant X," and downstream services can enforce that claim rather than trusting an HTTP header.

Shared infrastructure components — caches, queues, search indexes — are where cross-tenant leakage tends to sneak in, because they're often treated as internal plumbing rather than tenant-boundary-crossing surfaces. A shared Redis instance needs tenant-namespaced keys and ACLs, not just a naming convention engineers are trusted to follow. Resource quotas and limit ranges per namespace contain the noisy-neighbor problem — one tenant's runaway job shouldn't be able to starve compute for everyone else — though that's an availability concern more than a confidentiality one, and it's worth being honest that quotas manage the symptom, not the root cause of a poorly isolated shared resource.

Identity, authorization, and blast radius

Every credential in the system should be scoped to a tenant boundary and short-lived. Session tokens carry a tenant_id claim, minted by a central identity provider and validated independently at each service boundary — not just at the API gateway. Centralizing authorization decisions at the gateway is tempting because it's simpler, but it makes the gateway a single point of failure for isolation: any service reachable directly, or any internal call that skips the gateway, inherits no enforcement at all. Policy evaluation with something like OPA/Rego at multiple choke points, rather than one, means a bypass anywhere still hits an enforcement point somewhere else.

The underlying design assumption has to be that any individual service will eventually be compromised — through a dependency vulnerability, a misconfigured secret, or a supply-chain issue. The question isolation architecture actually answers is what happens next: can that compromised service's identity read another tenant's KEK from the KMS, or bypass another tenant's RLS role? If the answer is no because the compromised service's own credentials are scoped to one tenant and one KEK, the blast radius stays contained to that tenant. Tamper-evident, per-tenant audit logs — append-only, independently exportable — matter here too, not as a preventive control but because when isolation does fail somewhere, the ability to reconstruct exactly what was accessed and by what identity is what turns an unbounded incident into a bounded, provable one.

Trade-offs and what we would not do

We would not build dedicated infrastructure per tenant as the default, even though it's the simplest isolation story to explain — the operational cost of patching and monitoring N stacks compounds faster than most teams expect, and it doesn't actually eliminate the need for the same identity and key-scoping discipline once you're managing N of anything. We would also not treat application-layer tenant filtering as sufficient isolation on its own; it's the right place for the primary scoping logic because it's where business rules for cross-tenant sharing and admin access legitimately live, but it should never be the only enforcement point.

The honest cost of RLS-plus-envelope-encryption as the pooled default is real: query complexity goes up, index design becomes less forgiving, and every new access path — a reporting job, a data export feature, an admin tool — has to be built with the same tenant-scoping discipline as the primary application, or it becomes the gap that isolation architecture exists to prevent. That discipline is a genuine ongoing engineering cost, not a one-time architectural decision, and it's worth budgeting for as such rather than treating isolation as something a migration finishes once and for all.

Building something like this?

We engineer secure, regulated, and AI-driven systems at this depth. Tell us what you are building and we will help you architect it.

Start Your Project