Skip to main content
the auth layer

JWKS Endpoint Configuration and the Cache Invalidation Problem

7 min read Chapter 38 of 45

JWKS Endpoint Configuration and the Cache Invalidation Problem

The Assumption

Key rotation is an atomic operation. Generate new key, swap it in, done. The assumption: all resource servers will immediately use the new key for validation.

Resource servers cache the JWKS response. Spring Security’s NimbusJwtDecoder caches the JWK set and refreshes it based on cache headers or a default TTL. When you rotate the signing key on the authorization server, the resource servers continue using the cached (old) key set. Tokens signed with the new key are rejected until the cache expires.

In a microservice architecture with 15 services, each with its own JwtDecoder instance and its own cache, the cache refresh is staggered. Some services accept the new tokens within minutes. Others reject them for hours. The result: intermittent authentication failures that are nearly impossible to debug because they depend on which service handles the request and when that service last refreshed its JWKS cache.

The Attack

Scenario: Emergency rotation after key compromise. The private signing key is leaked. It was accidentally committed to a Git repository, or extracted from a misconfigured key vault. The attacker can now forge tokens. Every second counts.

You generate a new key and remove the old key from the JWKS endpoint. But resource servers have the old key cached. They continue accepting forged tokens signed with the compromised key for the duration of the cache TTL. If the cache TTL is 5 minutes, you have a 5-minute window where the attacker’s forged tokens are accepted. If it is 24 hours, you have a 24-hour window.

The Spec or Mechanism

JWKS Cache Behavior in NimbusJwtDecoder

Spring Security’s NimbusJwtDecoder uses the Nimbus JOSE+JWT library internally. The default JWKSource implementation (RemoteJWKSet) caches the JWK set and refreshes it when:

  1. The cache TTL expires (default: 5 minutes with Nimbus, configurable).
  2. A token references a kid (key ID) that is not in the cached JWK set, triggering an immediate refresh.

The second behavior is the key to graceful rotation. If new tokens include a kid that the resource server has not seen, the resource server fetches the JWKS endpoint immediately, without waiting for cache expiry.

HTTP Cache Headers

The JWKS endpoint should set Cache-Control headers:

  • max-age=300 (5 minutes) during normal operation.
  • max-age=0, no-cache during emergency rotation.

Resource servers that respect these headers will adjust their cache behavior accordingly.

The Implementation

Authorization Server: Multi-Key JWKS Endpoint

// VULNERABLE: Single key, atomic swap
@Bean
public JWKSource<SecurityContext> vulnerableJwkSource() {
    RSAKey currentKey = generateRsaKey("key-1");
    // When you replace key-1 with key-2, all cached copies of key-1
    // continue to be used for validation until cache expires
    return new ImmutableJWKSet<>(new JWKSet(currentKey));
}
// HARDENED: Multi-key source with rotation phases
@Bean
public JWKSource<SecurityContext> jwkSource(KeyRotationService keyRotationService) {
    return (jwkSelector, context) -> {
        JWKSet jwkSet = keyRotationService.getCurrentJwkSet();
        return jwkSelector.select(jwkSet);
    };
}

@Service
public class KeyRotationService {

    private final KeyRepository keyRepository;

    /**
     * Returns all active keys: the current signing key and any keys
     * in the introduction or retirement phase.
     */
    public JWKSet getCurrentJwkSet() {
        List<RSAKey> activeKeys = keyRepository.findByStatusIn(
            List.of(KeyStatus.ACTIVE, KeyStatus.SIGNING, KeyStatus.RETIRING));

        List<JWK> jwkList = activeKeys.stream()
            .map(this::toPublicJwk)
            .collect(Collectors.toList());

        return new JWKSet(jwkList);
    }

    /**
     * Returns the current signing key (only one key should have SIGNING status).
     */
    public RSAKey getSigningKey() {
        return keyRepository.findByStatus(KeyStatus.SIGNING)
            .map(this::toPrivateJwk)
            .orElseThrow(() -> new IllegalStateException("No signing key configured"));
    }

    private JWK toPublicJwk(RSAKey key) {
        return key.toPublicJWK(); // Strip private components
    }
}

Resource Server: kid-Aware Cache Refresh

@Bean
public JwtDecoder jwtDecoder() {
    // NimbusJwtDecoder with automatic kid-based cache refresh
    NimbusJwtDecoder decoder = NimbusJwtDecoder
        .withJwkSetUri("https://auth.saas.example/oauth2/jwks")
        .jwsAlgorithm(SignatureAlgorithm.RS256)
        .build();

    // Customize cache to respect Cache-Control headers
    // and refresh on unknown kid
    decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
        JwtValidators.createDefaultWithIssuer("https://auth.saas.example"),
        new JwtTimestampValidator(Duration.ofSeconds(30))
    ));

    return decoder;
}

Emergency Rotation: Force Cache Invalidation

@RestController
@RequestMapping("/admin/keys")
public class KeyRotationController {

    private final KeyRotationService keyRotationService;
    private final ServiceRegistryClient serviceRegistry;

    /**
     * Emergency key rotation: revoke compromised key and notify all services.
     */
    @PostMapping("/emergency-rotate")
    @PreAuthorize("hasAuthority('SCOPE_admin:keys')")
    public ResponseEntity<Map<String, Object>> emergencyRotate(
            @RequestParam String compromisedKeyId,
            @RequestParam String reason) {

        // 1. Generate new signing key
        String newKeyId = keyRotationService.generateAndActivateNewKey();

        // 2. Immediately remove compromised key from JWKS
        keyRotationService.revokeKey(compromisedKeyId, reason);

        // 3. Set JWKS cache headers to no-cache
        keyRotationService.setEmergencyCacheHeaders();

        // 4. Notify all resource servers to clear their JWKS cache
        List<String> services = serviceRegistry.getAllResourceServers();
        Map<String, String> refreshResults = new LinkedHashMap<>();

        for (String service : services) {
            try {
                // Each service exposes a /admin/jwks/refresh endpoint
                serviceRegistry.notifyJwksRefresh(service);
                refreshResults.put(service, "refreshed");
            } catch (Exception e) {
                refreshResults.put(service, "failed: " + e.getMessage());
            }
        }

        // 5. Log the rotation event for audit
        keyRotationService.logRotationEvent(
            compromisedKeyId, newKeyId, reason, refreshResults);

        return ResponseEntity.ok(Map.of(
            "new_key_id", newKeyId,
            "revoked_key_id", compromisedKeyId,
            "service_refresh_results", refreshResults
        ));
    }
}

Resource Server: Cache Refresh Endpoint

@RestController
@RequestMapping("/admin/jwks")
public class JwksCacheController {

    private final JwtDecoder jwtDecoder;

    /**
     * Force JWKS cache refresh. Called by authorization server
     * during emergency key rotation.
     */
    @PostMapping("/refresh")
    @PreAuthorize("hasAuthority('SCOPE_admin:internal')")
    public ResponseEntity<Void> refreshJwksCache() {
        if (jwtDecoder instanceof NimbusJwtDecoder nimbusDecoder) {
            // Force next token validation to fetch fresh JWKS
            // NimbusJwtDecoder does not expose cache clearing directly,
            // so we recreate the decoder or use a wrapper
            nimbusDecoder.setJwkSetUri("https://auth.saas.example/oauth2/jwks");
        }
        return ResponseEntity.ok().build();
    }
}

The Verification

@SpringBootTest
class JwksCacheRotationTest {

    @Autowired
    private KeyRotationService keyRotationService;

    @Autowired
    private MockMvc mockMvc;

    @Test
    void tokensSignedWithPreviousKeyStillAcceptedDuringRotation() throws Exception {
        // Get current signing key
        RSAKey oldKey = keyRotationService.getSigningKey();
        String oldKid = oldKey.getKeyID();

        // Sign a token with the old key
        String tokenWithOldKey = signToken(oldKey, "alice", Duration.ofMinutes(5));

        // Introduce new key (phase 1)
        String newKid = keyRotationService.introduceNewKey();

        // Old token should still be accepted (old key is still in JWKS)
        mockMvc.perform(get("/api/projects")
                .header("Authorization", "Bearer " + tokenWithOldKey))
            .andExpect(status().isOk());

        // Activate new key for signing (phase 2)
        keyRotationService.activateKey(newKid);

        // Sign a token with the new key
        RSAKey newKey = keyRotationService.getSigningKey();
        String tokenWithNewKey = signToken(newKey, "alice", Duration.ofMinutes(5));

        // Both tokens should be accepted
        mockMvc.perform(get("/api/projects")
                .header("Authorization", "Bearer " + tokenWithOldKey))
            .andExpect(status().isOk());

        mockMvc.perform(get("/api/projects")
                .header("Authorization", "Bearer " + tokenWithNewKey))
            .andExpect(status().isOk());
    }

    @Test
    void revokedKeyRejectedAfterEmergencyRotation() throws Exception {
        RSAKey compromisedKey = keyRotationService.getSigningKey();
        String tokenWithCompromisedKey = signToken(
            compromisedKey, "alice", Duration.ofMinutes(5));

        // Emergency rotation: revoke the compromised key
        keyRotationService.generateAndActivateNewKey();
        keyRotationService.revokeKey(compromisedKey.getKeyID(), "test compromise");

        // Token signed with revoked key should be rejected
        // (after JWKS cache refresh)
        refreshJwksCache();

        mockMvc.perform(get("/api/projects")
                .header("Authorization", "Bearer " + tokenWithCompromisedKey))
            .andExpect(status().isUnauthorized());
    }

    @Test
    void unknownKidTriggersAutoRefresh() throws Exception {
        // Introduce a new key on the auth server
        String newKid = keyRotationService.introduceNewKey();
        keyRotationService.activateKey(newKid);

        // Sign a token with the new key (kid not in resource server's cache)
        RSAKey newKey = keyRotationService.getSigningKey();
        String token = signToken(newKey, "alice", Duration.ofMinutes(5));

        // Resource server should auto-refresh JWKS because kid is unknown
        mockMvc.perform(get("/api/projects")
                .header("Authorization", "Bearer " + token))
            .andExpect(status().isOk());
    }
}

The third test validates the most important property: when a token arrives with a kid that is not in the cached JWKS, the resource server automatically fetches the latest JWKS and accepts the token. This means phase 1 (introduce) only needs to last long enough for services that do not receive tokens with the new kid during the introduction window.

Resource servers cache the JWKS response. The cache TTL is typically 5 minutes to 24 hours depending on the library and configuration. During that window, tokens signed with the new key are rejected by any resource server still holding the old JWKS in cache. A key rotation that does not account for cache TTL causes authentication failures proportional to the number of cached resource servers.