Product documentation
Multi-tenancy & the multi-issuer model
One hub deployment, many tenants — each tenant its own OIDC issuer with a dedicated signing key, isolated by the tnt claim and a trusted-issuer SSRF boundary.
Multi-tenancy & the multi-issuer model
Thoryn is multi-tenant to the core. A single hub deployment hosts many tenants, and each
tenant is given the cryptographic isolation of a dedicated OIDC issuer — not a shared
issuer with a tenant flag. This page explains that model: how a tenant becomes its own
issuer, how the tnt claim isolates tenants at runtime, how the customer-plane resource
servers validate per-tenant signatures without opening an SSRF hole, and how tenant scoping
is enforced in the database.
This is the deep version of white paper §4.2.
Each tenant is its own issuer
The tenant addressing scheme is:
| Tenant | Issuer (iss) | Signing key (kid) | JWKS |
|---|---|---|---|
| default | https://hub.<platformDomain> | sas-ecdsa-jwt-key | …/oauth2/jwks |
{slug} | https://{slug}.hub.<platformDomain> | tenant-{slug} (tenant-{slug}-v1) | https://{slug}.hub.<platformDomain>/oauth2/jwks |
<platformDomain> is the deployment-wide suffix (OAUTHY_TENANCY_PLATFORM_DOMAIN; on staging
.hub.stg.thoryn.org). Every tenant subdomain resolves to the same hub deployment at the
network layer — the tenancy is logical, keyed off the request Host:
TenantResolutionFilterruns before authentication and token issuance. It derives the tenant from the requestHostheader, populates aTenantContext, and serves that tenant's discovery document and keys.- At sign time, the per-tenant Vault/OpenBao Transit signing key is selected from that same
context (see Token lifecycle → signing),
so a token minted for tenant
acmeis signed bytenant-acmeand carriesiss = https://acme.hub.<platformDomain>.
The result: thousands of tenants on one deployment, each with its own issuer identity, its own key, its own key-rotation schedule, and its own JWKS — the isolation of a dedicated IdP without the cost of a dedicated deployment.
Sources: adrs/2026-06-08-multi-issuer-customer-plane-token-validation.md,
servers/authorization-hub/.../tenancy/TenantResolutionFilter.kt.
The tnt claim — isolation at runtime
Cryptographic issuer separation answers which signature validates. Runtime isolation —
what the caller may do — rides a separate mechanism: the tnt claim.
TenantClaimCustomizer writes the tenant identifier as a tnt claim into every access token
and OIDC ID token, read from the TenantContext at JWT-encoding time. Its behaviour is
deliberately asymmetric:
- Access token — fail closed. For a human principal, a missing tenant context throws
invalid_requestat mint time. Atnt-less access token would otherwise slip through to the gateway orproduct-apiand fail there with a less useful 401; failing at the source produces a clean error. - ID token — soft.
tntis added when the context is available and skipped silently otherwise. The ID token does not gate resource-server access, so a missingtntthere is informational only (it lets the console detect which hub tenant a user belongs to).
Downstream, the customer plane treats tnt as the authoritative tenant and re-checks it as
defence in depth — it does not trust the gateway. product-api's TenantClaimFilter
runs on every authenticated request and:
- rejects a token with a missing or blank
tntwith 401 (atnt-less token is malformed for the customer plane); - stashes the validated tenant as a request attribute so downstream code filters by it without re-parsing the JWT.
Cross-tenant access returns 404, never 403
A foreign-tenant resource id resolves to 404 Not Found, never 403 Forbidden. This is a privacy invariant, not a politeness convention: a 403 would confirm that the resource exists in another tenant, giving an attacker an existence oracle to enumerate ids. Returning 404 for both "does not exist" and "exists in another tenant" removes the oracle. Sub-resource lookups stay tenant-scoped in the service layer so this holds uniformly.
The tenant is never in the URL path
As of the no-tenant-in-path ADR (adrs/2026-07-14-customer-plane-no-tenant-in-path.md),
no customer-plane public path carries the tenant — it is always the tnt claim. Endpoints
are tenant-less (/api/v1/auth-policy, not /api/v1/tenants/{id}/auth-policy). This removes
a whole bug class: with no tenant in the path, a caller cannot even express cross-tenant
access, and the former path-versus-claim reconciliation logic simply disappears. The one
exception is the network-isolated /internal/** server-to-server surface, which is
authenticated by service identity rather than a tenant-admin token — there is no tnt claim
to read there, so the path is the right channel.
Sources: servers/authorization-hub/.../authorization/TenantClaimCustomizer.kt,
servers/product-api/.../tenant/TenantClaimFilter.kt,
adrs/2026-04-25-customer-plane-product-api.md,
adrs/2026-07-14-customer-plane-no-tenant-in-path.md.
Validating per-tenant issuers — the SSRF boundary
Because the console re-authenticates against a tenant's subdomain when a user switches
workspace, a customer-plane resource server can receive a token signed by any tenant's
key. The three resource servers — the hub's /account/** chain, product-api (servlet), and
the api-gateway (reactive) — must therefore validate per-tenant issuers consistently.
Doing that naively (fetch JWKS from whatever iss the token claims) would be a classic SSRF
sink. The platform closes it with a single, framework-agnostic allowlist.
TrustedTenantIssuers — the one source of truth
com.devnow.core.common.security.TrustedTenantIssuers.isTrusted(iss) returns true only
when the iss is either:
- the configured default hub issuer (
https://hub.<platformDomain>), matched verbatim; or https://{slug}.hub.<platformDomain>where{slug}matches the tenant-slug grammar and names a tenant that exists and is not suspended in the consuming service's own tenant registry.
Any other iss returns false, and no JWKS fetch is ever attempted for it. The class is
deliberately strict when parsing a candidate issuer — it requires exactly the https scheme,
rejects any path / query / fragment / userinfo / explicit port, and requires the slug to be a
single DNS label (no embedded dot). That single-label rule mirrors the hub's own
TenantResolutionFilter.extractSubdomainSlug exactly, so the set of issuers the predicate
trusts is precisely the set the hub will actually serve keys for. Double-label hosts and
subdomain-takeover variants fail the check:
// TrustedTenantIssuers — the slug grammar (identical to the hub's).
val SLUG_PATTERN: Regex = Regex("^[a-z0-9]([a-z0-9-]{1,61}[a-z0-9])$")The registry lookup is wrapped in a short-TTL (~60 s) positive-and-negative cache keyed by slug, so validation does not hit Postgres on every request; a freshly-created tenant becomes validatable within the TTL, a suspended tenant stops validating within it. A lookup failure fails closed (treats the issuer as untrusted) and is not cached, so the next request retries.
JWKS transport — a fixed in-cluster host, only the Host header varies
Even for a trusted tenant issuer, the decoder does not dial the public tenant subdomain.
TenantIssuerJwksTransport fetches JWKS from the fixed in-cluster hub Service
(http://thoryn-hub:<port>/oauth2/jwks) and sets an HTTP Host: {slug}.hub.<platformDomain>
header so the hub's TenantResolutionFilter serves that tenant's keys. The outbound URL host
is therefore always the fixed in-cluster service — never attacker-influenced — and the
only thing that varies is a Host header derived from an already-allowlisted slug. This keeps
the SSRF surface closed and decouples token validation from public-ingress health.
Per-issuer decoders are resolved via Spring Security's issuer-resolving multi-tenant support —
JwtIssuerAuthenticationManagerResolver (servlet: hub /account/**, product-api) and
JwtIssuerReactiveAuthenticationManagerResolver (reactive: api-gateway, wired in
GatewayMultiIssuerConfig). Each per-issuer decoder pins ES256 (per the platform's
JWS-algorithm-allow-list rule) and validates iss, exp, and nbf; the gateway additionally
validates aud.
Bearer token (iss = https://acme.hub.<domain>)
│
▼
TrustedTenantIssuers.isTrusted(iss) ── false ─▶ 401 (no JWKS fetch)
│ true
▼
per-issuer JwtDecoder (ES256)
│ fetch JWKS from FIXED host http://thoryn-hub:PORT/oauth2/jwks
│ with Host: acme.hub.<domain>
▼
validated JWT → tnt-claim isolation + scope gates unchanged
Multi-issuer validation is strictly additive: it only widens which signatures validate.
The tnt-claim re-checks, the 404-not-403 rule, and the tenant:* scope gates are unchanged.
The gateway is fully reactive, so it must not block the Netty event loop on the per-request
tenantExists check; it holds an in-memory slug snapshot refreshed by a scheduled reactive
query and answers the predicate as a pure in-memory membership test.
Sources: core/lib/common/.../security/TrustedTenantIssuers.kt,
…/TenantIssuerJwksTransport.kt, …/TenantIssuerJwtDecoderFactory.kt,
core/starters/gateway/.../multiissuer/GatewayMultiIssuerConfig.kt,
adrs/2026-06-08-multi-issuer-customer-plane-token-validation.md.
Tenant scoping in the schema — the composite client key
Tenant isolation is also a database-schema property, not only a runtime one. Migration
V28__multi_tenancy_isolation.sql (SSO-770) closed a set of cross-tenant gaps in the hub
schema. The load-bearing change:
The
client(andfederation_member_client) table'sclient_idoriginally carried a globalUNIQUEconstraint.V28replaced it with a compositeUNIQUE (tenant_id, client_id).
-- V28__multi_tenancy_isolation.sql
ALTER TABLE client DROP CONSTRAINT IF EXISTS client_client_id_key; -- the old global UNIQUE
ALTER TABLE client
ADD CONSTRAINT client_tenant_client_id_key UNIQUE (tenant_id, client_id);With the global constraint, two tenants could not register a client with the same logical
client_id, and a no-tenant findByClientId returned whichever row happened to be first —
a cross-tenant leak. The composite key means the same client_id is a distinct client in
each tenant, and lookups are tenant-scoped. V28 applied the same fix to
federation_member_client, added a tenant_id column (and index) to client_claims_webhook
and federation_session, and re-created the dependent foreign keys against the new composite
keys so cross-tenant join rows fail at the database level.
Correction to prior documentation. An earlier internal page stated that "the
clienttable's unique constraint is onclient_idalone." That was true beforeV28and is now incorrect — sinceV28the constraint is the compositeUNIQUE (tenant_id, client_id). This guide reflects the current schema.
Platform clients — the one deliberate cross-tenant carve-out
A small, operator-controlled set of platform clients (the Thoryn-operated console BFF and
the thoryn CLI) are allowed to resolve across every tenant, because a user must be able to
sign in at any tenant's subdomain with the same client. These clients are seeded once under
tenant_id = 'default'. On a tenant-scoped lookup miss, the hub's registered-client
repository falls back to a no-tenant lookup only when the requested client_id is on the
oauthy.hub.platform-client-ids allowlist; the resolved row is returned regardless of the
tenant it was seeded under. A tenant-registered client that is not on the allowlist still
resolves to null on a cross-tenant miss — it is never cross-tenant-resolved. The carve-out
is explicit and narrow, and does not weaken isolation for customer-registered clients.
Sources: servers/authorization-hub/src/main/resources/db/migration/V28__multi_tenancy_isolation.sql,
CLAUDE.md (multi-issuer validation; composite (tenant_id, client_id) key),
the hub's platform-client allowlist (oauthy.hub.platform-client-ids).
See also
- Token lifecycle — where the
tntclaim and the per-tenant signing key are applied during token minting. - Federation runtime — how per-tenant federation members are resolved and mirrored to the hub.
- Security & compliance dossier — the full SSRF-containment story and the CI guards that hold these invariants.