Skip to content

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) enforces tnt on every request through a tenant claim filter, and gates each endpoint with a tenant:* scope. A request whose tnt does 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:

  1. the configured default hub issuer (https://hub.<platformDomain>), exact-match, no registry lookup; or
  2. https://{slug}.hub.<platformDomain> where {slug}:
    • parses as a bare https host 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.

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 uses sas-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 — tnt is a signed claim minted at the hub, re-verified downstream.
  • It does not rely on the UI hiding cross-tenant resources — the server returns 404 regardless of what any client renders.