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, keepingcode_challengeand 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_TOKENcookie 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:
| Stage | Customizer(s) | What it does |
|---|---|---|
| Federation normalization | FederationClaimsCustomizer, StepUpClaimsCustomizer | Emits 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 profile | Rfc9068AccessTokenCustomizer | Shapes the access token to the RFC 9068 JWT-access-token profile |
| Elevated scope union | ElevatedScopeCustomizer | Unions 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 binding | DpopAccessTokenCustomizer, MtlsAccessTokenCustomizer, FapiAccessTokenCustomizer | Adds 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 enrichment | ClientClaimsEnricher, WebhookClaimsEnricher | Applies per-client claim rules and calls out to per-client enrichment webhooks (see below) |
| Tenant | TenantClaimCustomizer, TenantCapabilityTokenCustomizer | Mints the tnt claim and applies the capability-entitlement scope gate (see below) |
| Token exchange | TokenExchangeJwtCustomizer | Customizes 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):
emailalways travels withemail_verified. When the member asserts an email but no verification status, the hub emitsemail_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
namefromgiven_name+family_name; Identity Service already does that at source, and deriving it in two places invites drift. subis never forwarded. The hub's subject is its own (the registryplatform_subject); because the customizer runs afterJwtGeneratorhas set the subject, a forwarded upstreamsubwould 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 /userinfo — never 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:
ClientClaimsEnricherreads aclaims_mappingJSON 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 likegroups/hdare theidp_attributesfollow-on), andprincipal_name(the subject id). A PII guard restrictsidp_claimrules targeting the access token to a client-configuredallowed_access_token_claimsallowlist, so PII does not leak into machine-to-machine tokens. The enricher never throws — an individual bad rule is logged and skipped.WebhookClaimsEnricheris 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
- Federation runtime — the redirect mechanics that produce the authenticated principal this page mints tokens for.
- Multi-tenancy & the multi-issuer model — the
tntclaim, per-tenant issuers, and how relying parties validate per-tenant signatures. - Security & compliance dossier — key custody,
historical-jwks, and the CI guards (JWS-algorithm allow-lists, no unbounded blocking calls) in depth.