For a multi-service platform, how would you design the JWT validation strategy end-to-end (defaults, issuer, audience, clock skew, key rotation) and what are the failure modes you must guard against?
answer
- createDefaultWithIssuer + per-service AudienceValidator
- verify signature by kid; rotate with overlapping keys
- tight skew, fix clocks not skew
- fail closed, never alg:none
- encoder iss/aud/exp must match validators (one contract)
basics
~20 sEach resource server keeps the default timestamp validator, adds issuer validation bound to the trusted authorization server, and adds an audience validator for its own identifier. Keep clock skew tight, verify signatures against a rotating JWK set by kid, and fail closed on any error.
solid answer
~50 sThe strategy: every resource server composes a `DelegatingOAuth2TokenValidator<Jwt>` starting from `JwtValidators.createDefaultWithIssuer(issuer)` (timestamp + issuer) and adds a per-service `AudienceValidator` bound to that service's own identifier — so a token minted for service A can't be replayed against service B. Signature verification runs first in the decoder against the authorization server's JWK Set URI, with keys keyed by `kid` so rotation is seamless (old and new keys coexist; the decoder picks by header `kid`). Clock skew stays small (e.g. 30s) given NTP-synced hosts. The main failure modes to guard: dropping defaults by misusing `setJwtValidator`; missing audience checks enabling cross-service token reuse; over-wide skew extending token life; accepting alg:none or symmetric confusion; stale JWK cache during rotation; and swallowing validation errors instead of failing closed. On the mint side, `NimbusJwtEncoder` sets iss/aud/exp consistently so both ends share one contract.
code
java · 16 lines// Shared factory used by every resource server for consistent rules
static JwtDecoder hardenedDecoder(String jwkSetUri, String issuer, String thisServiceId) {
NimbusJwtDecoder decoder = NimbusJwtDecoder
.withJwkSetUri(jwkSetUri) // verifies signature, selects key by kid
.build();
OAuth2TokenValidator<Jwt> withIssuer =
JwtValidators.createDefaultWithIssuer(issuer); // exp/nbf + iss
OAuth2TokenValidator<Jwt> audience =
new AudienceValidator(thisServiceId); // aud == this service
decoder.setJwtValidator(
new DelegatingOAuth2TokenValidator<>(withIssuer, audience));
return decoder;
}
// A validation failure returns errors -> Spring answers 401 (fail closed).go deeper
Can name issuer, audience, and expiry as things to check.
Composes the validator chain correctly and keeps the defaults.
Explains signature-by-kid rotation, clock skew tradeoffs, and encoder/validator contract symmetry.
Owns platform-wide policy: shared validator library, key rotation, algorithm allow-list, fail-closed posture, revocation via short-lived tokens/introspection.
## Goal In a platform where one authorization server (or Spring Authorization Server) issues JWT access tokens consumed by many resource servers, validation must ensure each token is (a) authentically from the trusted issuer, (b) unexpired, and (c) intended for *this* service. Getting any of these wrong creates auth bypass or token-reuse holes. ## The per-service validator stack Every resource server builds the same shape: ```java OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuer); OAuth2TokenValidator<Jwt> audience = new AudienceValidator(thisServiceId); decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(withIssuer, audience)); ``` - **`JwtTimestampValidator`** (from the defaults) enforces `exp`/`nbf` with a clock skew. - **`JwtIssuerValidator`** pins `iss` to the one trusted authorization server, rejecting tokens minted elsewhere. - **`AudienceValidator`** (custom) pins `aud` to *this* service's identifier — the critical cross-service isolation control. ## Signature verification & key rotation Before validators run, `NimbusJwtDecoder` verifies the JWS signature. Configure it with the JWK Set URI (`NimbusJwtDecoder.withJwkSetUri(...)` or `issuer-uri` auto-config, which also derives the JWK set from issuer metadata). The decoder caches keys and selects by the token header's **`kid`**: - **Rotation**: the AS publishes both the retiring and the new key in its JWK set; it starts signing with the new `kid` while old tokens still verify against the old key. Resource servers refresh the cache and match by `kid` — no coordinated downtime. - **Cache staleness**: if a new `kid` appears before the cache refreshes, verification fails until refresh; ensure sane cache TTL / refresh-on-unknown-kid behavior. ## Clock skew policy Default 60s is generous. With reliable NTP, tighten to ~30s (or less) to reduce the window a stolen/expired token remains usable. Never widen skew to "fix" flaky clocks — fix the clocks. Remember skew effectively extends token lifetime on both `exp` and `nbf`. ## Mint side symmetry `NimbusJwtEncoder` must set `iss` (matching each RS's `JwtIssuerValidator`), `aud` (the target service id(s) the RS's `AudienceValidator` expects), `iat`, and `exp` (short — minutes to an hour). Encoder and validators are two ends of one contract; a mismatch (e.g. AS omits `aud`, or uses a different issuer string) silently rejects every token. ## Failure modes to guard against 1. **Dropping the defaults** — calling `setJwtValidator` with only a custom validator removes timestamp/issuer checks. Always compose from `createDefault*`. 2. **No audience check** — any valid token from the issuer works everywhere (confused-deputy). Audience is mandatory in multi-service setups. 3. **Over-wide clock skew** — silently lengthens token validity. 4. **Algorithm confusion / `alg:none`** — decoders must restrict accepted algorithms to those the JWK/issuer uses; never accept `none`; avoid RSA-public-key-as-HMAC-secret confusion. Nimbus + Spring defaults prevent this if configured with asymmetric JWKs. 5. **Stale/failed JWK fetch** — network issues fetching the JWK set cause outages or, if mishandled, verification gaps. Cache with refresh and fail closed. 6. **Swallowing errors / failing open** — a validation error must reject (401), never default-allow. `OAuth2TokenValidatorResult` errors must propagate. 7. **Missing `exp`/`iat` on minted tokens** — a token with no `exp` isn't expiry-checked by the default validator. 8. **Issuer string drift** — trailing slash / http vs https mismatch between `iss` claim and configured issuer causes blanket rejection. ## Reactive & consistency Mirror the same composition with `ReactiveJwtDecoder` for WebFlux services. Centralize the validator-building logic (a shared library/starter) so every service applies identical, correct rules rather than re-deriving them. ## When to go further For high-value flows consider sender-constrained tokens (DPoP/mTLS), shorter lifetimes with refresh, and token introspection for revocation — JWT self-validation alone can't revoke a token before `exp`.
- How does key rotation work without downtime, and what role does kid play?The authorization server publishes both the retiring and new keys in its JWK set and starts signing with the new key's kid. Resource-server decoders select the verifying key by the token header's kid, so old tokens still verify against the old key while new ones use the new key. When old tokens expire, the old key can be dropped.
- Why can't JWT self-validation handle immediate token revocation, and what compensates?A self-contained JWT is valid until its exp regardless of server state, so you can't revoke it early by validation alone. Compensate with short lifetimes plus refresh tokens, or use OAuth2 token introspection (opaque-token style) to check live revocation status.
saying these in an interview costs you the question
- Relying on issuer/signature checks alone and skipping audience in a multi-service platform
- Widening clock skew to mask unsynchronized clocks
- Accepting alg:none or allowing algorithm confusion
- Failing open when the JWK set fetch fails or a validator errors
- Assuming JWT validation can revoke a token before expiry