Skip to content

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 | database

Tenant 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.com

Custom 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.

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:

@Bean
fun 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.

All tenants share tables; every row carries tenant_id. Three defenses run together:

  1. Explicit tenant scoping in every query.
  2. A Hibernate @Filter that auto-appends tenant_id = :current to reads on tenant-scoped entities — a query that forgets its tenant predicate still can’t leak.
  3. A write-guard interceptor rejecting any write whose entity tenant ≠ current tenant.

Zero extra infrastructure. The right default for most SaaS.

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 default

The 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 + mirror

provision 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.

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: 5

Dynamic fleets (databases provisioned at runtime, credentials in a vault) replace the registry with a bean — IAM’s default politely loses to yours:

@Bean
fun tenantDataSourceRegistry(vault: VaultTemplate): IamTenantDataSourceRegistry =
IamTenantDataSourceRegistry { tenantId -> myPoolFor(vault.lookup(tenantId)) }

An unregistered tenant fails fast — never a silent fallback into the shared database.

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.