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:
| Status | Meaning |
|---|---|
ISSUED | Active, current in its chain |
ROTATED | Superseded by a child on legitimate rotation |
REVOKED | Invalidated (family revocation, or aged-out predecessor) |
REPLAYED | A 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 siblingREPLAYEDand 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:
| Mode | Behaviour on mismatch |
|---|---|
OFF | No checking (default; backward compatible) |
LOG | Logged as WARN; token still accepted |
SOFT | Triggers the theft-detection flow (full revocation when the theft action is REVOKE_AUTHORIZATION) |
STRICT | Immediately 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
DpopAccessTokenCustomizerthen addscnf.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'scnf.jkt. - mTLS (RFC 8705). When the client authenticates with a TLS client certificate, the hub's
MtlsAccessTokenCustomizeraddscnf.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).