Skip to content

Product documentation

Token security

Refresh-token rotation with replay/family revocation, device-fingerprint binding, single-use codes with PKCE, DPoP/mTLS sender-constrained tokens, and RFC 9457 error hygiene.

Token security

Tokens are the platform's primary credential, so their lifecycle carries the most defence. This page covers refresh-token rotation and replay handling, optional device-fingerprint binding, authorization-code single-use, sender-constrained tokens (DPoP / mTLS), and the error hygiene that keeps token contents out of responses.

Refresh-token rotation, replay & family revocation

The hub rotates refresh tokens on every use and treats a replayed (already-rotated) token as a theft signal. Rotation and detection are implemented in OAuth2RefreshTokenHandler. Each stored refresh token carries a status:

StatusMeaning
ISSUEDActive, current in its chain
ROTATEDSuperseded by a child on legitimate rotation
REVOKEDInvalidated (family revocation, or aged-out predecessor)
REPLAYEDA sibling detected during a theft event

Replay → family revocation. When a rotation is requested against a parent that is already ROTATED, or a sibling ISSUED token is found for the same grandparent, the handler has detected a replay: the legitimate client and an attacker both hold a token descended from the same parent. The configured theft action then applies:

  • REVOKE_AUTHORIZATION (default) — revoke the entire authorization (all refresh tokens revoked, access tokens deleted) and refuse to persist the new token (a "phantom" token). This is the RFC 9700 §2.2.2 automatic-reuse-detection response: neither the attacker nor the victim keeps a working token, forcing re-authentication.
  • REJECT_ONLY — mark the replayed sibling REPLAYED and reject only that token, leaving the authorization alive. An opt-out for service accounts where full-family revocation is too blunt.

Forking mode. A per-client setting controls whether concurrent chains (forks) are allowed. In forking mode, sibling detection still catches the attacker when the legitimate client next rotates; in no-forking mode a rotated-parent replay throws invalid_grant immediately.

Additional bounds (per registered client):

  • Chain-length cap — a rotation that would exceed the configured chain length is rejected with invalid_grant, bounding how long a single authorization can be extended by rotation.
  • Sliding-window expiry with an absolute cap — a sliding-window client's new token expiry is extended on each rotation but bounded by an absolute-max TTL anchored at the chain root, so a continuously-rotated token cannot outlive its absolute ceiling. The sliding math fails safe: on any error it falls back to the non-sliding TTL rather than propagating a 500.

Every replay/theft event is logged (RefreshTokenTheft / RefreshTokenChainLimit) with the client id, subject, token id, and action — never the token value.

Source: OAuth2RefreshTokenHandler.kt, TheftDetectionAction.kt.

Device-fingerprint binding

On issuance the hub captures a coarse request fingerprint and stores it against the refresh token: the User-Agent, a truncated IP prefix (/24 for IPv4, /48 for IPv6 — coarse by design, so a mobile client roaming within a network is not falsely flagged), and an optional X-Device-ID header. Enforcement on subsequent rotations is a per-client policy:

ModeBehaviour on mismatch
OFFNo checking (default; backward compatible)
LOGLogged as WARN; token still accepted
SOFTTriggers the theft-detection flow (full revocation when the theft action is REVOKE_AUTHORIZATION)
STRICTImmediately returns invalid_grant, regardless of the theft action

Fingerprint binding is defence-in-depth on top of rotation: a stolen token replayed from a different device/network is caught even before the rotation-replay logic would fire.

Source: FingerprintEnforcement.kt; OAuth2RefreshTokenHandler.captureFingerprint().

Authorization codes: single-use + PKCE

Authorization codes are single-use and PKCE-protected (RFC 7636, enforced). On redemption Spring Authorization Server invalidates the code — and on detecting a reused code invalidates the derived tokens — while the hub's OAuth2AuthorizationCodeHandler deletes the code row on the invalidated path. A code is therefore usable exactly once, and reuse is detected.

Accuracy note. A DB-level atomic-claim primitive (claimCodeReturning, a compare-and-set that would additionally close a concurrent double-redemption race) exists in the repository and is exercised by tests, but its wiring into the live redemption path was reverted (SSO-893) after it regressed the federation login flow, and a federation-safe re-implementation is tracked under SSO-801. Single-use and reuse-detection are in force today; the concurrent-double-spend hardening is the specific piece not currently on the live path. This dossier states that rather than claiming an atomic guarantee the code does not currently provide.

Source: OAuth2AuthorizationCodeHandler.kt (see the SSO-893 comment block); white-paper standards table (PKCE enforced).

Sender-constrained tokens (DPoP / mTLS)

Bearer tokens can be sender-constrained so a lifted token is useless without proof of the holder's key. Both bindings are supported and add a confirmation (cnf) claim to the access token; both are opt-in per registered client (DpopTokenSettings / MtlsTokenSettings):

  • DPoP (RFC 9449). Spring AS 7 validates the DPoP proof at the token endpoint; the hub's DpopAccessTokenCustomizer then adds cnf.jkt — the SHA-256 JWK thumbprint of the proof's public key (RFC 9449 §6.1). A resource server binds the presented DPoP proof to the token's cnf.jkt.
  • mTLS (RFC 8705). When the client authenticates with a TLS client certificate, the hub's MtlsAccessTokenCustomizer adds cnf.x5t#S256 — the base64url SHA-256 of the leaf certificate's DER encoding (RFC 8705 §3.1) — binding the token to that certificate.

Tokens issued to requests without a DPoP proof or client certificate are unaffected (plain bearer), so the constraint is a per-client uplift, not a platform-wide breaking change.

Source: DpopAccessTokenCustomizer.kt, MtlsAccessTokenCustomizer.kt; white-paper standards table (RFC 9449 / 8705).

Error hygiene (RFC 9457)

Every customer-plane error response (product-api, the api-gateway, and the broker-internal server-to-server surface) is RFC 9457 problem-details (application/problem+json), emitted through one shared ProblemDetailsWriter. Clients see one error shape platform-wide — type, status, title, detail, plus an errorCode extension.

The security-relevant property is sanitisation by construction: the detail string is surfaced verbatim to the caller and must never contain a tenant id (tnt value), token or credential contents (sub, kid, scope set, presented JWS bytes), or an internal exception message. Those belong in the WARN log only. This closed the pentest leak (SSO-834) where a hand-rolled {error, message} envelope echoed expected-vs-actual tnt values back to the caller, letting them probe for valid tenant ids by misrouting requests. The writer takes a stable detail parameter and never reads request contents, so the leak cannot recur by construction.

The hub's OAuth/OIDC protocol endpoints are the deliberate exception: RFC 6749 §5.2 mandates the {error, error_description} shape there, which the hub honours. A CI guard (check-no-custom-error-shape.sh) fails the build on any new {error, message} envelope outside that allow-list.

Source: core/lib/common/.../error/ProblemDetailsWriter; CLAUDE.md → "Customer-plane error responses are RFC 9457"; scripts/check-no-custom-error-shape.sh (SSO-834 / SSO-1122).