Skip to content

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:

TenantIssuer (iss)Signing key (kid)JWKS
defaulthttps://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:

  • TenantResolutionFilter runs before authentication and token issuance. It derives the tenant from the request Host header, populates a TenantContext, 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 acme is signed by tenant-acme and carries iss = 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_request at mint time. A tnt-less access token would otherwise slip through to the gateway or product-api and fail there with a less useful 401; failing at the source produces a clean error.
  • ID token — soft. tnt is added when the context is available and skipped silently otherwise. The ID token does not gate resource-server access, so a missing tnt there 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 tnt with 401 (a tnt-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:

  1. the configured default hub issuer (https://hub.<platformDomain>), matched verbatim; or
  2. 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 (and federation_member_client) table's client_id originally carried a global UNIQUE constraint. V28 replaced it with a composite UNIQUE (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 client table's unique constraint is on client_id alone." That was true before V28 and is now incorrect — since V28 the constraint is the composite UNIQUE (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 tnt claim 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.