As a platform architect, how would you handle multi-tenant JWT issuers and decide between local JWT validation and opaque-token introspection?
answer
- JwtIssuerAuthenticationManagerResolver, whitelist only
- local JWT: fast, offline, weak revocation
- opaque: introspect per request, instant revoke
- hybrid: JWT reads + introspect sensitive writes
- never trust arbitrary iss
basics
~20 sFor many issuers, use a JwtIssuerAuthenticationManagerResolver so each tenant's iss routes to its own decoder. Choose local JWT validation for speed and offline verification; choose opaque-token introspection when you need instant revocation and central control, at the cost of a network call per request.
solid answer
~50 sMulti-tenant: instead of one hard-wired decoder, register a `JwtIssuerAuthenticationManagerResolver` seeded with the trusted issuer URIs (or a dynamic resolver). The bearer filter reads the token's `iss`, and the resolver picks/caches a per-issuer `AuthenticationManager` (each with its own NimbusJwtDecoder and JWKS). Untrusted issuers are rejected — you must whitelist. Local JWT vs opaque: JWTs are self-contained and verified locally against cached JWKs — fast, horizontally scalable, works if the auth server is briefly down — but revocation is weak (valid until exp), so use short lifetimes. Opaque tokens carry no claims; the resource server calls the auth server's introspection endpoint (`opaqueToken()` / `SpringOpaqueTokenIntrospector`) every request — giving instant revocation and central policy at the cost of latency and a hard runtime dependency. Common compromise: short-lived JWTs plus refresh tokens, or JWT with a revocation cache/introspection on sensitive operations.
code
java · 18 lines// Multi-tenant: route by the token's issuer to a per-tenant decoder,
// trusting only a whitelist of issuers.
@Bean
SecurityFilterChain multiTenant(HttpSecurity http) throws Exception {
JwtIssuerAuthenticationManagerResolver resolver =
JwtIssuerAuthenticationManagerResolver.fromTrustedIssuers(
"https://auth.example.com/realms/tenant-a",
"https://auth.example.com/realms/tenant-b");
http
.authorizeHttpRequests(a -> a.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.authenticationManagerResolver(resolver));
return http.build();
}
// Opaque-token alternative (instant revocation, network call per request):
// http.oauth2ResourceServer(o -> o.opaqueToken(Customizer.withDefaults()));
// spring.security.oauth2.resourceserver.opaquetoken.introspection-uri=...client-id/secretgo deeper
Aware that opaque tokens exist and are introspected, unlike self-contained JWTs.
Explain the revocation tradeoff and that multi-issuer needs more than one decoder.
Configure JwtIssuerAuthenticationManagerResolver and opaqueToken introspection and justify a choice.
Set fleet-wide policy: tenancy model, JWT-vs-introspection per workload, revocation SLA, JWKS caching, algorithm allow-list, and startup resilience.
## The single-decoder limit The basic setup binds one `JwtDecoder` (one issuer, one JWKS) to the filter chain. A SaaS platform where each tenant has its own realm/issuer needs to accept tokens from *many* issuers and validate each against the right keys — while rejecting unknown issuers. ## JwtIssuerAuthenticationManagerResolver Spring provides `JwtIssuerAuthenticationManagerResolver`, an `AuthenticationManagerResolver<HttpServletRequest>` that dispatches by the token's `iss` claim: ```java JwtIssuerAuthenticationManagerResolver resolver = JwtIssuerAuthenticationManagerResolver.fromTrustedIssuers( "https://auth.example.com/realms/tenant-a", "https://auth.example.com/realms/tenant-b"); http.oauth2ResourceServer(o -> o.authenticationManagerResolver(resolver)); ``` Internally, for each new trusted issuer it lazily builds a `JwtDecoder` (via issuer discovery) wrapped in a `JwtAuthenticationProvider`/`AuthenticationManager` and caches it. The bearer token's `iss` selects the manager. Only whitelisted issuers are honored — a token from an unlisted issuer yields 401. For fully dynamic tenancy, supply a custom `AuthenticationManagerResolver` that validates the issuer against a tenant registry before minting a manager (never trust an arbitrary `iss` and fetch its JWKS — that's an open door). ## Local JWT validation — pros/cons Pros: self-contained (claims travel in the token); **verified locally** against cached JWKs — no per-request call to the auth server; scales horizontally; resilient to brief auth-server downtime; low latency. Cons: **revocation is hard** — a stolen but unexpired token stays valid until `exp`; token size grows with claims; key rotation must be handled (JWKS caching does). Mitigation: short access-token lifetimes (minutes) + refresh tokens; a denylist/cache checked for high-risk actions. ## Opaque tokens + introspection — pros/cons An **opaque token** is a random string with no readable claims. The resource server validates it by calling the authorization server's **introspection endpoint** (RFC 7662), which returns whether it's active plus its claims. Spring config: ```yaml spring.security.oauth2.resourceserver.opaquetoken: introspection-uri: https://auth.example.com/oauth2/introspect client-id: resource-server client-secret: ... ``` ```java http.oauth2ResourceServer(o -> o.opaqueToken(Customizer.withDefaults())); ``` Backed by `SpringOpaqueTokenIntrospector` producing a `BearerTokenAuthentication`. Pros: **instant revocation** (auth server is the source of truth every request); tiny tokens; claims never exposed to clients; central policy. Cons: a **network call per request** (latency, throughput ceiling); hard runtime dependency on the auth server; needs caching to scale (which reintroduces staleness). ## Choosing - High-throughput internal microservices, latency-sensitive → **local JWT**, short lifetimes. - Strict/regulated revocation (banking, admin consoles), low volume → **introspection**. - Hybrid: JWT for normal reads, introspection or a revocation cache for sensitive writes; or a gateway that introspects once and forwards a minted internal JWT. ## Additional principal-level concerns - **JWKS caching & rate-limiting**: tune refresh and guard against a rotation storm hammering the auth server; ensure overlap windows. - **Algorithm allow-listing** platform-wide to prevent alg-confusion. - **Audience & issuer policy** enforced uniformly (see the validator question) so a token can't hop services/tenants. - **Clock-skew standard** across the fleet. - **Observability**: log the `iss`/`sub`/`jti` (not the whole token) for audit; surface 401 reasons. - **Startup coupling**: issuer-discovery at boot vs pre-provisioned JWKS for air-gapped/offline resilience. ## Gotchas - `fromTrustedIssuers` requires an explicit whitelist — never build a resolver that trusts any issuer and fetches its keys. - Mixing `.jwt()` and `.opaqueToken()` in one chain isn't valid; pick one per filter chain (or split chains by matcher). - Introspection without caching can become the system bottleneck. - JWT revocation "solutions" (denylists) reintroduce shared state — weigh against just shortening lifetimes.
- A token arrives with an iss you don't recognize. What does JwtIssuerAuthenticationManagerResolver.fromTrustedIssuers do?It rejects it — only issuers on the explicit trust list get a decoder/AuthenticationManager; an unknown iss yields a 401. You must never dynamically trust an arbitrary issuer and fetch its JWKS, as that would let an attacker present a self-issued token.
- Your compliance team demands tokens be revocable within seconds. What changes?Pure local JWT can't guarantee that — a valid JWT lives until exp. Options: switch sensitive flows to opaque tokens + introspection (source of truth per request), drastically shorten JWT lifetimes plus refresh, or add a shared revocation denylist/introspection check on high-risk endpoints.
saying these in an interview costs you the question
- Building a resolver that trusts any issuer and fetches its JWKS on the fly
- Claiming local JWT validation supports instant revocation
- Ignoring the per-request latency/dependency cost of introspection
- Mixing .jwt() and .opaqueToken() in one filter chain
- Forgetting introspection needs caching to scale