Multi-Tenancy & Isolation
Every IAM entity is tenant-scoped, but multi-tenancy is off by default — single-tenant apps see zero behaviour change. Turning it on is one flag; how the tenant is identified per request and how strongly tenants are separated are two further, independent choices.
iam: multi-tenancy: enabled: true resolution: [jwt] # ordered strategy chain, e.g. [host, header, jwt] isolation: row-level # or: schema | databaseTenant resolution — an ordered, fail-closed chain
Section titled “Tenant resolution — an ordered, fail-closed chain”iam.multi-tenancy.resolution lists strategies in the order they are tried per request:
| Strategy | Source |
|---|---|
jwt (default) |
tid claim in the access token — signature-verified, no DB lookup |
header |
X-Tenant-ID header (name configurable via header-name); UUID or slug |
host |
the request hostname — a custom-domain lookup first, then subdomain parsing |
Each strategy answers one of three ways, and the chain semantics are strict:
| Outcome | Meaning | Chain behaviour |
|---|---|---|
Resolved |
tenant positively identified | wins — chain stops, context set |
NoSignal |
nothing this strategy understands | next strategy; all-silent → default tenant |
Unknown |
request claims a tenant that doesn’t exist | request rejected with 404 |
The Unknown row is the important one: a typo’d tenant subdomain or an unknown X-Tenant-ID
value is an explicit claim, and it fails closed — rejected before authentication, never
silently served from the default tenant’s data. (404, not 401: no credential can make an
unprovisioned hostname valid, and 404 leaks nothing about which tenants exist.)
A typical SaaS production setup with a test-friendly fallback:
iam: multi-tenancy: enabled: true resolution: [host, header, jwt] base-domain: yourapp.comCustom strategies: publish a IamTenantResolver bean (return the sealed IamTenantResolution) and
list its strategy name in resolution. A name colliding with a built-in replaces it. Unknown
names fail the boot. The resolved tenant is held in a request-scoped TenantContext that
drives everything below.
The SaaS domain story
Section titled “The SaaS domain story”Subdomains (the default shape). Set base-domain: yourapp.com and put host in the chain:
acme.yourapp.com resolves tenant slug acme against the tenant registry — an unknown slug
under your base domain is rejected (fail closed), while hostnames not under your base domain
(health checks, foreign hosts) are simply no signal. Reserved labels (www, api, admin,
app, mail) never resolve. Wildcard DNS (*.yourapp.com) plus a wildcard or SAN certificate
at your fronting proxy is all the infrastructure this needs.
Customer-owned custom domains. The hostname is a lookup, not a parse. Implement the
IamTenantHostnameSource SPI over your custom-domain mapping table; the host strategy consults
it with the FULL hostname before any subdomain parsing:
@Beanfun customDomains(repo: CustomDomainRepository) = IamTenantHostnameSource { hostname -> repo.findVerified(hostname)?.tenantId }Operationally: the customer CNAMEs login.acme.com → your edge, you record the mapping (after
domain-ownership verification), and your fronting proxy terminates TLS for the custom domain
(ACME/on-demand certificates — the library never sees TLS, only the Host header your proxy
forwards). Unmapped hostnames fall through to the base-domain rules.
Tests and internal tooling. Keep header in the chain after host: suites and admin
scripts send X-Tenant-ID (UUID or slug) without faking hostnames. The header is validated
against the registry — an unknown value is rejected, exactly like an unknown subdomain.
Isolation strategies
Section titled “Isolation strategies”row-level (default)
Section titled “row-level (default)”All tenants share tables; every row carries tenant_id. Three defenses run together:
- Explicit tenant scoping in every query.
- A Hibernate
@Filterthat auto-appendstenant_id = :currentto reads on tenant-scoped entities — a query that forgets its tenant predicate still can’t leak. - A write-guard interceptor rejecting any write whose entity tenant ≠ current tenant.
Zero extra infrastructure. The right default for most SaaS.
schema — schema-per-tenant
Section titled “schema — schema-per-tenant”One PostgreSQL schema per tenant (tenant_<uuid>), routed by Hibernate’s native SCHEMA
multi-tenancy: each session’s connection is switched to the tenant’s schema on checkout and
reset on release. There is no tenant predicate to forget — the other tenant’s rows are
physically elsewhere.
iam: multi-tenancy: isolation: schema # default-schema: public # where the DEFAULT tenant lives; blank = vendor defaultThe default tenant is the control plane. It lives in the default schema (public on
PostgreSQL) — the same place all boot-time seeding wrote its data — and is never given a
tenant_<uuid> schema. Only non-default tenants route to per-tenant schemas. This is built in;
authenticated requests for the default tenant work over HTTP out of the box (guarded by an
HTTP-level e2e in the library’s suite).
Provisioning a tenant is two calls on the IamTenantProvisioning facade — the intended
seam for a host’s control-plane module:
val tenant = iamTenantProvisioning.createTenant("Acme Corp", "acme") // control-plane row// (optional: your own per-tenant work — host migrations, metadata — goes here)iamTenantProvisioning.provision(tenant.id!!) // schema + tables + mirrorprovision is idempotent (IAM keeps its iam_flyway_history inside each tenant schema) and
finishes by mirroring the tenant’s registry row — same UUID — into the fresh schema, so
in-schema foreign keys are immediately satisfiable and the JWTs minted inside the tenant route
back to the right schema. Alternatively, publish a TenantProvisioningEvent after creating a
tenant and provisioning runs after your transaction commits. Never call provision inside the
transaction that created the tenant row.
database — database-per-tenant
Section titled “database — database-per-tenant”Strongest isolation: each tenant has its own database and connection pool, routed per session. The default tenant routes to the primary datasource (same control-plane rule as schema mode). Static fleets configure connections directly:
iam: multi-tenancy: isolation: database databases: "3f2504e0-4f89-41d3-9a0c-0305e82c3301": url: jdbc:postgresql://tenant-a-db:5432/iam username: iam password: ${TENANT_A_DB_PASSWORD} max-pool-size: 5Dynamic fleets (databases provisioned at runtime, credentials in a vault) replace the registry with a bean — IAM’s default politely loses to yours:
@Beanfun tenantDataSourceRegistry(vault: VaultTemplate): IamTenantDataSourceRegistry = IamTenantDataSourceRegistry { tenantId -> myPoolFor(vault.lookup(tenantId)) }An unregistered tenant fails fast — never a silent fallback into the shared database.
Choosing
Section titled “Choosing”| row-level | schema | database | |
|---|---|---|---|
| Infra cost | none | low | high (pool per tenant) |
| Blast radius of a bug | filter + guard | schema boundary | physical boundary |
| Backup/restore per tenant | hard | possible | trivial |
| Tenant count sweet spot | thousands | hundreds | tens |
| Compliance story | logical | strong | strongest |
Start row-level; move a demanding customer to schema/database when contracts require it —
the switch is configuration, not a rewrite.