Skip to content

Product documentation

Token lifecycle

From pushed authorization request to signed token — the authorize/federation/token path, the claims-enrichment customizer chain, and ES256 signing via Vault/OpenBao Transit with per-tenant keys.

Token lifecycle

This page follows a token from the moment a relying party starts an authorization to the moment the hub returns a signed JWT — through the pushed-authorization-request front door, the federation round-trip, the claims-enrichment pipeline, and the signing bridge into Vault/OpenBao Transit. It is the deep companion to Federation runtime (which covers the redirect mechanics) and white paper §5–§6.

The path

1. PAR       POST /oauth2/authorize/par     → opaque request_uri  (RFC 9126)
2. Authorize GET  /oauth2/authorize?request_uri=… → federation redirect (user not yet authenticated)
3. Federation  … external IdP round-trip …   → FED_TOKEN + authenticated principal
4. Token     POST /oauth2/token (code+PKCE)  → access token · ID token · refresh token
  • PAR-first (RFC 9126). Authorization parameters are pushed server-to-server and referenced by an opaque request_uri, keeping code_challenge and other sensitive parameters out of the browser URL bar. PKCE (RFC 7636) is enforced.
  • Authorize triggers the federation redirect when the user is not authenticated; the mechanics (the FED_TOKEN cookie bridge, the stateless session model) are in Federation runtime.
  • Token exchanges the authorization code (single-use, atomically consumed) for the token set. Everything interesting about what goes into those tokens happens here.

The hub also supports the device authorization grant (RFC 8628), token exchange (RFC 8693), refresh, client credentials, revocation (RFC 7009), and introspection (RFC 7662); the standards matrix in the reference docs tracks the exact profile of each. The claims and signing described below apply to the JWT-minting grants uniformly.

Claims enrichment — a customizer chain

The hub builds tokens with Spring Authorization Server's JwtGenerator, wrapping a chain of OAuth2TokenCustomizer<JwtEncodingContext> beans assembled in SecurityConfig.tokenGenerator. Each customizer is small and single-purpose, and the order is deliberate — later customizers depend on claims that earlier ones set. Grouped by role:

StageCustomizer(s)What it does
Federation normalizationFederationClaimsCustomizer, StepUpClaimsCustomizerEmits the standard OIDC claims the federation member asserted (email + email_verified, name, given_name, family_name, picture) plus the session-context claims (auth_time, amr, acr) that conditional-access reads back at refresh, and applies step-up outcomes. Runs first, so per-client enrichment sees an already-normalized claim set (see below).
Access-token profileRfc9068AccessTokenCustomizerShapes the access token to the RFC 9068 JWT-access-token profile
Elevated scope unionElevatedScopeCustomizerUnions a subject's currently-active PIM just-in-time elevations and entitlement-package grants into the access token's scope claim, filtered to the tenant:* namespace (see below). Access-token only
Proof-of-possession bindingDpopAccessTokenCustomizer, MtlsAccessTokenCustomizer, FapiAccessTokenCustomizerAdds cnf.jkt for DPoP (RFC 9449), cnf.x5t#S256 for mTLS-bound tokens (RFC 8705), and clamps exp for FAPI clients — layered so both cnf forms can coexist (FAPI 2.0)
Per-client enrichmentClientClaimsEnricher, WebhookClaimsEnricherApplies per-client claim rules and calls out to per-client enrichment webhooks (see below)
TenantTenantClaimCustomizer, TenantCapabilityTokenCustomizerMints the tnt claim and applies the capability-entitlement scope gate (see below)
Token exchangeTokenExchangeJwtCustomizerCustomizes exchanged tokens (RFC 8693)

Three of these are worth zooming into.

Federation claim normalization and /userinfo

FederationClaimsCustomizer is the hub's normalization stage. A federation login completes in a filter; the token is minted later, on a different request — so the member's claims are bridged across that gap through a short-lived, GETDEL-consumed Redis entry keyed by the login's principal (the registry platform_subject). At mint time the customizer emits the standard OIDC set the member asserted, under one contract (FederationStandardClaims) shared by all three federation login paths — the UUID multi-provider callback, the oauth2Login success handler, and the headless /login/complete credential channel (SSO-2028):

  • email always travels with email_verified. When the member asserts an email but no verification status, the hub emits email_verified: false — an un-asserted status must never read as verified at a relying party.
  • Absent claims are omitted, never invented. The hub does not derive name from given_name + family_name; Identity Service already does that at source, and deriving it in two places invites drift.
  • sub is never forwarded. The hub's subject is its own (the registry platform_subject); because the customizer runs after JwtGenerator has set the subject, a forwarded upstream sub would overwrite it — so it is excluded from the forwarded set by construction.

HubOidcUserInfoMapper mirrors the same claims at GET /userinfo, scope-gated per OIDC Core §5.4 (profile / email / address), so /userinfo agrees with the ID token for the same login. This is what makes the console's per-member claim-mapping wizard observable in issued tokens.

Provider extensions ride idp_attributes, opt-in per member (SSO-2002). Non-standard extension claims the wizard maps — groups (Okta/Entra), hd (Google) — are not standard OIDC claims and are kept out of the top level (where they would collide with an RP's own or a future standard claim). When a federation member sets providerConfig.emit_idp_attributes, those extensions are carried as a single nested idp_attributes object on the ID token and, under the profile scope, at /userinfonever on the access token. Default off: emitting group membership is a deliberate per-connection act. See ADR 2026-07-20-idp-attributes-namespaced-extension-claims.md.

Per-client claims enrichment (the shipped extension point)

There are two ways a client can shape its own claims:

  • ClientClaimsEnricher reads a claims_mapping JSON configuration on the client and applies rules: static (a fixed literal), idp_claim (copy a named claim the member asserted — from a live OAuth2 principal, or the normalized federation claims already on the token — into a possibly-renamed client claim; provider extensions like groups/hd are the idp_attributes follow-on), and principal_name (the subject id). A PII guard restricts idp_claim rules targeting the access token to a client-configured allowed_access_token_claims allowlist, so PII does not leak into machine-to-machine tokens. The enricher never throws — an individual bad rule is logged and skipped.
  • WebhookClaimsEnricher is the programmable version: a per-client, token-issuance-time webhook that can add claims. It is SSRF-guarded (the outbound URL passes through the outbound-URL guard) and HMAC-signed so the receiver can verify authenticity. This is the claims-enrichment webhook the white paper lists as shipped under Extensibility.

The broader Actions / Hooks programmable auth pipeline (inline modify/veto stages plus async event stages) is roadmap — designed and in build, not described here as if it exists today.

Elevated scope union — PIM and entitlements

ElevatedScopeCustomizer is the access-token channel for just-in-time privilege. A subject's currently-active PIM elevations (see Just-in-time privileged elevation) and entitlement-package grants live in product-api; on every access-token mint for a client whose tenant enabled the pim / entitlements enrichment source, this customizer resolves them over the same HMAC-signed, IO-dispatched enrichment transport WebhookClaimsEnricher uses (so the signing thread is never blocked), filters the response to the tenant:* namespace, and unions the survivors into the scope claim. It runs after Rfc9068AccessTokenCustomizer (so scope is finalized) and before the capability gate below — the piece that makes an activated privilege actually enforce at a resource server.

It is access-token only (scopes are authorization, never identity) and fail-safe for privilege: any enrichment failure issues the token without the elevated scopes — an under-privileged 403 the subject retries, never over-privileged and never a failed login. Only a positive, signed enrichment response can add a scope. See ADR 2026-08-03-pim-entitlement-token-enrichment.md.

The capability-entitlement gate

TenantCapabilityTokenCustomizer runs on every access-token mint and drops tenant:<prefix>.* scopes whose capability family the tenant is not entitled to. The gate is generic and drop-only — it never adds a scope — and it fails open when the entitlement map does not know a prefix. Because it runs after the elevated-scope union above, it also backstops it: an elevated scope for a family the tenant is not entitled to is still dropped, so PIM/entitlement elevation can never smuggle a scope past the tenant's capability entitlement. Consistent with the product-agnostic-hub decision, the family map it reads is operator configuration, not a compiled-in literal: the hub attaches no product meaning to any particular scope string. See the Multi-tenancy page for how scopes relate to the tnt boundary.

Sources: servers/authorization-hub/.../config/SecurityConfig.kt (tokenGenerator and combinedJwtCustomizer); the customizers under servers/authorization-hub/.../authorization/ (TenantClaimCustomizer, Rfc9068AccessTokenCustomizer, ClientClaimsEnricher, ElevatedScopeCustomizer, ElevatedScopeExtractor, TenantCapabilityTokenCustomizer) and …/webhook/WebhookClaimsEnricher.kt.

Refresh tokens — replay and family revocation

Refresh tokens are generated by the hub's own OauthyOAuth2RefreshTokenGenerator and rotate on use. The hub tracks refresh-token families: if a previously rotated token is presented again — the signature of a replay or a stolen token — the entire family is invalidated, so a leaked refresh token cannot be used to mint an indefinite session. This sits alongside the access/ID token generators in a DelegatingOAuth2TokenGenerator, which also produces opaque tokens for the non-JWT paths.

Source: servers/authorization-hub/.../authorization/OauthyOAuth2RefreshTokenGenerator.kt.

Signing via Vault/OpenBao Transit

All hub-minted JWTs are signed ES256 (ECDSA P-256, SHA-256), and the private key never enters the application. Signing is delegated to the secrets backend's Transit engine; the hub sends header + claims and receives only the signature.

The bridge into Spring Authorization Server

Spring AS signs through a JwtEncoder. The platform's implementation is a thin adapter:

// core/starters/signing — SasSigningEncoder bridges Spring AS to the SigningStrategy.
class SasSigningEncoder(private val signingStrategy: SigningStrategy) : JwtEncoder {
    override fun encode(parameters: JwtEncoderParameters): Jwt {
        val token = signingStrategy.sign(parameters.jwsHeader, parameters.claims)  // → Transit
        return Jwt(token, parameters.claims.issuedAt, parameters.claims.expiresAt,
                   parameters.jwsHeader.headers, parameters.claims.claims)
    }
}

SigningStrategy is the vendor-boundary interface (sign, getPublicJWKs, getPublicJWKsFor); its Transit implementation performs the signing server-side. Keeping this seam is deliberate — it is why the platform could migrate the backend from HashiCorp Vault to OpenBao (the Linux Foundation's MPL-2.0 fork, on persistent storage with auto-unseal) with no rewrite of core/lib/signing/ — the Transit API is the abstraction, and OpenBao is API-compatible. See the vault-to-openbao ADR (adrs/2026-06-07-vault-to-openbao.md).

Per-tenant keys, resolved at sign time

Per-tenant key isolation is a key-naming concern. TenantSigningConfig injects a TenantSlugResolver that reads the current tenant from TenantContextHolder at call time, so the signing strategy selects the tenant's Transit key (tenant-{slug}); a tenant without a custom slug falls back to the shared platform key (sas-ecdsa-jwt-key). Each per-tenant key has its own ACL, version history, and rotation schedule.

Why ES256, and what rotation looks like

Per the JWT-signing-strategy ADR (adrs/2026-04-26-jwt-signing-strategy.md), ES256 is the default because its signatures are 64 bytes (versus 256 for RSA-2048) — meaningful across millions of Authorization: Bearer headers — verification is faster on every relying party and resource server, and P-256 is the most broadly supported JWT curve. Rotation is a Transit concern: a scheduled key-rotation job rotates the Transit key, the JWS header stamps kid = <name>-v<version>, and the JWKS endpoint surfaces every active (non-archived) key version so tokens minted seconds before a rotation still verify. Relying parties verify offline against the cached JWKS — there is no introspection round-trip on the hot path. Token minting depends on backend availability (a backend outage fails new authorizations closed); verification does not.

Long-lived signed artefacts that must be verified against retired kids are served from a separate historical-jwks endpoint, so the live JWKS stays small and cache-fresh — the detail lives in the security dossier.

Sources: core/starters/signing/.../SasSigningEncoder.kt, core/lib/signing/common/.../SigningStrategy.kt, servers/authorization-hub/.../config/TenantSigningConfig.kt, adrs/2026-04-26-jwt-signing-strategy.md, adrs/2026-06-07-vault-to-openbao.md.

See also