Product documentation
Tenant isolation
How Thoryn keeps thousands of tenants isolated on one deployment: the tnt claim, cross-tenant 404, the trusted-issuer SSRF boundary, per-tenant crypto, and tenant-never-in-URL.
Tenant isolation
Thoryn is multi-tenant to the core: each tenant is its own OIDC issuer, and one deployment hosts many tenants. Isolation rests on four independent mechanisms — a claim carried in every token, a uniform not-found response on cross-tenant access, a cryptographic issuer boundary, and per-tenant key material. No single one is load-bearing on its own.
The tnt claim — carried everywhere, re-checked everywhere
Every token the hub mints carries a tnt claim naming the tenant it belongs to. Every
downstream service re-checks it:
- The customer plane (
product-api) enforcestnton every request through a tenant claim filter, and gates each endpoint with atenant:*scope. A request whosetntdoes not match the resource it addresses is rejected. - The hub derives tenant context from the request
Host(TenantResolutionFilter) for its per-tenant endpoints, and serves that tenant's keys and discovery document.
Isolation is enforced in depth: even after the multi-issuer
change widened which signatures validate, the tnt re-checks and scope gates that govern
what a caller may do are unchanged. Widening signature validation never widened authorisation.
Source: ADR 2026-06-08-multi-issuer-customer-plane-token-validation.md §5;
servers/product-api/ tenant-claim filter.
Cross-tenant access returns 404, never 403
When a caller addresses a resource that exists but belongs to another tenant, the platform
returns 404 Not Found, never 403 Forbidden. This is a privacy invariant, not a
politeness convention: a 403 would confirm the resource exists, letting a caller probe for
valid ids across tenant boundaries. 404 is symmetric with a resource that genuinely does not
exist, so no existence signal leaks.
This rule holds across every tnt-scoped customer-plane endpoint — OAuth-client CRUD,
federation members, audit, and the rest. A regression that "helpfully" returns 403 on
cross-tenant access is a privacy defect.
Source: CLAUDE.md → "Console configuration surface" and "Customer plane"; ADR
2026-04-25-customer-plane-product-api.md.
The tenant is never in the URL path
A customer-plane public path never carries the tenant id. The tenant is always the
caller's tnt claim — there is no /api/v1/tenants/{tenantId}/... and no /tenants/me/...
shape. Reading the tenant from the URL would create a second, spoofable source of truth that
could disagree with the cryptographically-asserted tnt claim.
This is enforced structurally: the CI guard
check-no-tenant-in-customer-plane-path.sh scans every product-api
controller (not just the diff) and fails the build if any Spring mapping literal contains a
path-carried tenant id.
Source: ADR 2026-07-14-customer-plane-no-tenant-in-path.md;
scripts/check-no-tenant-in-customer-plane-path.sh (SSO-1937).
The trusted-issuer boundary (SSRF-safe)
Because each tenant is its own issuer, the customer-plane resource servers validate
per-tenant issuers, not a single default issuer. That widening is bounded by an explicit
allowlist that doubles as the SSRF boundary. It is implemented once, framework-agnostically, in
com.devnow.core.common.security.TrustedTenantIssuers, so the three resource servers (hub
/account/**, product-api, api-gateway) cannot drift apart.
What is trusted
isTrusted(iss) returns true only when the token's iss is either:
- the configured default hub issuer (
https://hub.<platformDomain>), exact-match, no registry lookup; or https://{slug}.hub.<platformDomain>where{slug}:- parses as a bare
httpshost authority — no path, query, fragment, userinfo, or explicit port; - is a single DNS label under the platform suffix (an embedded dot is rejected, which defeats double-label and subdomain-takeover variants);
- matches the tenant-slug grammar
^[a-z0-9]([a-z0-9-]{1,61}[a-z0-9])$(3–63 chars, no leading/trailing hyphen); and - names a tenant that exists and is not suspended in the consuming service's own tenant registry.
- parses as a bare
Any other iss returns false. Slug derivation mirrors the hub's TenantResolutionFilter
exactly, so the set of issuers this predicate trusts is precisely the set the hub will actually
serve keys for.
Why it is the SSRF boundary
When isTrusted returns false, the decoder factories never attempt a JWKS fetch for that
issuer — the request is rejected with 401 before any outbound HTTP. A forged or
attacker-controlled iss can therefore neither be accepted nor used to steer an outbound
request at an internal host or a cloud-metadata endpoint.
The registry lookup fails closed: if the tenant-existence check throws, the issuer is treated as untrusted (and the failure is not cached, so the next request retries). The result cache is a short-TTL (≈60 s) positive-and-negative cache of the boolean existence result only — it never stores a decoder or any token material. A newly-created tenant becomes validatable within the TTL; a suspended tenant stops validating within the TTL.
The JWKS fetch host is fixed
For a trusted tenant slug, the per-tenant decoder fetches JWKS from a fixed in-cluster hub
Service and varies only the HTTP Host header so the hub serves that tenant's keys:
GET http://thoryn-hub:8080/oauth2/jwks ← outbound host is ALWAYS this fixed Service
Host: {slug}.hub.<platformDomain> ← only the Host header varies (from an
already-allowlisted slug)The outbound URL host is invariant and operator-configured; the token never influences it. This
is a second, structural SSRF guard complementing the allowlist — even a trusted slug only
ever produces a fetch to the one configured in-cluster URL (TenantIssuerJwksTransport).
Each per-issuer decoder additionally pins the ES256 JWS algorithm (per the platform-wide
algorithm allow-list guard) and validates iss, exp, and nbf; the
api-gateway also validates aud.
Source: core/lib/common/.../security/TrustedTenantIssuers.kt,
TenantIssuerJwksTransport.kt, TenantIssuer(Reactive)JwtDecoderFactory.kt; ADR
2026-06-08-multi-issuer-customer-plane-token-validation.md.
Per-tenant cryptographic isolation
Isolation extends into the cryptographic layer:
- Per-tenant signing keys. Tenant tokens are signed with a Vault/OpenBao Transit key named
for the tenant (
tenant-{slug}); the default tenant usessas-ecdsa-jwt-key. A compromise of one tenant's slug does not reveal another tenant's key, and each key has its own version history and rotation schedule. See signing & crypto. - Per-tenant HMAC on audit content, so the same subject or credential presented to two tenants does not produce a linkable identifier (plain hashing would). See signing & crypto.
- Version-prefixed identifiers. Any value computed with a tenant-scoped key is stored
version-prefixed as
v<n>:<hex>. Rotating a tenant's key on incident does not break the lookup or verification of historical rows — old rows verify against the retired version (retained in the backend for the row's retention horizon) while new rows use the rotated key.
Source: ADR 2026-04-26-jwt-signing-strategy.md; ADR 2026-05-09-verifiable-audit-chain.md;
CLAUDE.md → "Cryptographic / signing pipeline rules".
What isolation does not rely on
- It does not rely on the URL path carrying a correct tenant id (there is none — see above).
- It does not rely on the client honestly self-reporting its tenant —
tntis a signed claim minted at the hub, re-verified downstream. - It does not rely on the UI hiding cross-tenant resources — the server returns
404regardless of what any client renders.