Blueprint 04 · Trust & isolation
Multi-Tenant Isolation & Tamper-Evident Audit
In shared-infrastructure SaaS, a single leaked row can end the business. This blueprint makes tenant isolation a property of the architecture — derived top-down from the verified token, enforced in depth — and pairs it with an audit ledger you can prove was never altered.
Isolation you can't forget to apply
Most cross-tenant breaches aren't exotic. They're an endpoint that forgot a WHERE tenant_id = ?, a tenant identifier trusted from the request body, or a connection that leaked one customer's context into another's query because it was pooled. The defining idea of this blueprint is to make those mistakes structurally impossible rather than relying on every developer remembering every time.
Two principles do the heavy lifting. First, the tenant, the acting user and their role are resolved once, from the cryptographically verified session, and flow down through the request — they are never accepted from client-controlled input. Second, isolation is layered: even if the application layer slips, a second independent mechanism in the database refuses to return another tenant's data.
The client may say who it is; the system only believes the token. Tenant claims are validated against a registry of provisioned tenants, so a forged or misconfigured claim cannot bind a session to someone else's data. Authorisation is default-deny: an endpoint is protected unless it is explicitly marked public.
Four layers of the same guarantee
Top-down context binding
Middleware extracts tenant, user and roles from the verified token, validates the tenant against the provisioned registry, and builds a request-scoped context object that every downstream layer reads from.
Database-enforced isolation
Schema-per-tenant and/or row-level security keyed on a per-transaction session variable means the database itself filters by tenant — and the app connection runs as a non-privileged role so those rules actually bite.
Role-based access, default-deny
RBAC is enforced as a global concern via guards and per-route requirements, so a new endpoint inherits authentication and authorisation automatically and is locked unless deliberately opened.
Tamper-evident audit ledger
Every state change appends an audit entry whose hash chains to the previous one, committed in the same transaction as the change itself — so the record and the trail can never disagree, and any tampering breaks the chain.
Tenant context set on a connection must be scoped to the transaction (so it is reset when the connection returns to the pool) — otherwise one request's tenant can bleed into the next. Setting the scope per-transaction, and dropping to a non-superuser role before queries run, is what makes row-level security trustworthy under connection pooling.
Where each control lives
flowchart TB
C["Client"] -->|"token (bearer)"| GW["Gateway / middleware"]
GW --> V["Verify token
extract tenant · user · roles"]
V --> CHK{"Tenant in
provisioned registry?"}
CHK -->|no| DENY["Reject"]
CHK -->|yes| CTX["Request context
(server-trusted)"]
CTX --> RBAC["RBAC guard
default-deny"]
RBAC --> H["Handler / domain"]
H --> SESS["DB session:
set tenant scope (per-tx)
drop to non-privileged role"]
SESS --> DB[("Database
schema-per-tenant + row-level security")]
H --> AUD["Append audit entry
hash chained to previous"]
AUD --> DB
Isolation strategies, compared
| Strategy | Strength | Cost | Good fit |
|---|---|---|---|
| Shared table + row-level security | Simple ops, one schema, DB-enforced filter | One mistake in policy affects everyone; noisy-neighbour risk | Many small tenants, uniform data model |
| Schema-per-tenant | Strong logical separation, easy per-tenant export/migration | Schema proliferation; migrations run per tenant | Fewer, larger tenants; stricter isolation needs |
| Both (defense-in-depth) | Independent layers; a slip in one is caught by the other | Most moving parts | Domains where leakage is catastrophic |
| Database-per-tenant | Maximum isolation, blast-radius containment | Highest operational overhead and cost | Regulated, high-value, or sovereignty-bound tenants |
Change and audit commit together
sequenceDiagram
participant C as Client
participant M as Middleware
participant H as Handler
participant DB as Database
C->>M: Mutating request + token
M->>M: Verify · resolve tenant/role · validate registry
M->>H: Context (tenant, user, role)
H->>DB: BEGIN
H->>DB: SET LOCAL tenant scope + role
H->>DB: Apply change (RLS active)
H->>DB: Append audit entry (hash = H(prev || change))
H->>DB: COMMIT
DB-->>H: OK (or rollback both)
H-->>C: Result
If auditing is a separate, best-effort write, the trail drifts from reality the first time it fails. Committing the audit entry in the same transaction guarantees that a recorded change is an audited change — and an audited change is a real change. Serialising the chain (e.g. with an advisory lock) prevents forks under concurrency.
Five alternate use cases
Multi-org ledger platform
Each business banks its own data; row-level security plus a hash-chained ledger gives auditors a tamper-evident history and gives the platform a clean per-tenant export story.
Multi-clinic records system
Sensitive records demand both schema isolation and a dedicated access log; default-deny RBAC and per-read logging satisfy data-protection obligations without bespoke code per endpoint.
Jurisdiction-scoped portal
Tenancy maps to an administrative hierarchy; scope is taken from the token and injected into every query, so a district officer can never see another district's citizens.
Employer-of-record platform
Hundreds of employers share infrastructure; RLS contains the blast radius while the audit ledger documents every payroll and benefits change for dispute resolution.
Evidence & records vault
The tamper-evident chain is the product: anchoring reports to the ledger head proves an exported document reflects an unaltered history at a point in time.
Where isolation quietly fails
| Risk | Why it happens | Mitigation |
|---|---|---|
| Connection-pool bleed | Tenant context set at session scope survives into the next borrower of the connection | Scope context to the transaction; reset on release; drop to a non-privileged role so RLS enforces |
| Trusting client-supplied scope | Tenant id read from a header, body or query parameter | Derive scope only from the verified token; validate against the provisioned-tenant registry |
| Identifier injection | Schema or role names can't be parameterised and get interpolated | Allowlist / charset-validate all identifiers; parameterise every value |
| Audit as an afterthought | Best-effort logging that fails silently or can be edited | Co-commit with the change; chain hashes; serialise writes; restrict who can read/write the ledger |
| Migration drift | Schema-per-tenant means dozens of schemas to keep in lockstep | Templated, automated per-tenant migrations with verification; fail the deploy if any tenant lags |
The non-negotiables
- Bind tokens tightly: verify issuer and audience so a valid token for a different client or service account in the same realm is rejected.
- Keep any token-free development shortcut hard-gated to both an explicit dev auth-mode and a development environment, so it can never grant access in staging or production.
- Prefer bearer tokens over cookies where you can, so cross-origin rules are a backstop rather than a primary control; keep CORS an explicit allowlist and refuse wildcards in production.
- Log sensitive reads, not just writes, when the domain requires it — a separate access log answers "who looked at this record," which write audits alone cannot.
- Encrypt the most sensitive fields with authenticated encryption and a rotatable, versioned key (see Blueprint 03's key-provider seam), so a database leak is not automatically a data leak.
When to reach for it — and when not to
Use it when
- Multiple organisations share infrastructure and a cross-tenant leak would be catastrophic.
- You operate under data-protection or sector regulation that demands provable audit trails.
- Customers will ask for per-tenant data export, deletion, or residency guarantees.
- Disputes or investigations require reconstructing exactly who changed what, when.
Avoid / simplify when
- The system is single-tenant or internal-only — full multi-tenancy machinery is overhead.
- Data sensitivity is low and a lightweight scoped query is genuinely sufficient.
- You can't yet operate per-tenant migrations reliably — start with shared-table + RLS, add schema isolation later.
- Throughput needs conflict with serialising an audit chain — then batch or shard the ledger deliberately.
Want this pattern applied to your build?
These are the structures we reach for. Tell us the system you're trying to build.