Published on

Spring Security JWT 401 - JWK 키회전 캐시 해결

Authors

운영 중이던 Spring Security Resource Server에서 갑자기 JWT 검증이 실패하며 401 Unauthorized가 늘어나는 경우가 있습니다. 특히 IdP(예: Cognito, Auth0, Keycloak, Azure AD 등)가 JWK를 키 회전(key rotation)한 직후부터 간헐적으로 터지고, 시간이 지나면 자연히 사라지는 패턴이라면 원인은 대개 JWK 캐시와 키 회전 타이밍 불일치입니다.

이 글에서는 Spring Security의 JWT 검증 흐름에서 JWK가 어떻게 캐시되는지, 왜 키 회전 시점에 401이 발생하는지, 그리고 캐시를 안전하게 제어해서 장애를 줄이는 실전 해결책을 정리합니다.

관련해서 인증 플로우 디버깅이 필요하다면 OAuth PKCE invalid_grant·state 불일치 해결 가이드도 함께 참고하면 전체 인증 체인을 이해하는 데 도움이 됩니다.

증상: 키 회전 직후만 401이 튄다

다음과 같은 로그/현상이 반복되면 JWK 캐시 문제 가능성이 높습니다.

  • 특정 시점(대개 IdP 키 회전 직후)부터 401 급증
  • 같은 토큰이 어떤 인스턴스에서는 성공, 어떤 인스턴스에서는 실패
  • 몇 분에서 몇 시간 후 자연 회복
  • 로그에 JwtValidationException, Invalid signature, Signed JWT rejected 류 메시지
  • 토큰 헤더의 kid 값이 새로운 키인데, 서버는 이전 JWK 세트를 보고 있음

클라이언트가 들고 온 JWT는 헤더에 kid(Key ID)를 포함합니다. 리소스 서버는 kid에 해당하는 공개키를 JWK Set에서 찾아 서명을 검증합니다. 문제는 이 JWK Set을 매 요청마다 가져오지 않고, 성능을 위해 캐시한다는 점입니다.

원인: JWK Set 캐시가 새 키를 못 본다

Spring Security Resource Server의 기본 동작은 대략 다음과 같습니다.

  1. issuer-uri 또는 jwk-set-uri로부터 메타데이터 및 JWK Set을 조회
  2. JWK Set을 내부 캐시에 저장
  3. 각 요청의 JWT에서 kid를 읽고, 캐시된 JWK Set에서 매칭되는 키로 검증

키 회전이 발생하면 IdP는 새로운 키를 JWK Set에 추가하거나 기존 키를 교체합니다. 여기서 401이 발생하는 전형적 시나리오는 두 가지입니다.

1) IdP는 새 kid로 토큰을 발급했는데, 리소스 서버 캐시는 구버전

  • 클라이언트: 새 키로 서명된 토큰 수신(헤더 kid가 새 값)
  • 리소스 서버: 캐시된 JWK Set에는 해당 kid가 없음
  • 결과: 서명 검증 실패로 401

2) 분산 환경에서 인스턴스별 캐시 갱신 타이밍이 다름

  • 인스턴스 A: 캐시 갱신되어 새 키 인지
  • 인스턴스 B: 캐시가 아직 구버전
  • 로드밸런서 라우팅에 따라 성공/실패가 섞여 “간헐적 401”로 보임

이 문제는 K8s/ECS 같은 환경에서 특히 흔합니다. 장애 원인을 빨리 좁히는 운영 관점은 K8s CrashLoopBackOff 원인 10분내 찾는 법처럼 “증상-로그-재현-원인” 순으로 접근하는 게 좋습니다.

빠른 확인: 토큰의 kid와 JWK Set 비교

가장 먼저 할 일은 “실패한 토큰의 kid가 JWK Set에 존재하는지” 확인하는 것입니다.

1) 실패한 JWT에서 kid 확인

TOKEN="eyJhbGciOiJSUzI1NiIsImtpZCI6IjEyMy4uLiJ9.eyJpc3MiOiJodHRwczovL2lzc3VlciIsLi4ufQ.signature"

# 헤더 디코딩(kid 확인)
echo "$TOKEN" | cut -d '.' -f 1 | base64 -d 2>/dev/null | jq

환경에 따라 base64 패딩 문제로 실패할 수 있으니, 필요하면 python으로 디코딩해도 됩니다.

2) JWK Set에서 kid 존재 여부 확인

JWK_SET_URL="https://issuer.example.com/.well-known/jwks.json"

curl -s "$JWK_SET_URL" | jq '.keys[].kid'
  • 토큰 kid가 JWK Set에 없다면: 캐시 갱신 지연 또는 IdP 배포/전파 지연 가능성이 큽니다.
  • JWK Set에는 있는데도 실패한다면: 알고리즘 불일치, issuer/audience 설정 오류, 프록시/캐시가 JWK 응답을 잘못 캐싱하는 문제 등도 의심해야 합니다.

해결 전략 1: JWK 캐시 TTL과 리프레시 정책을 명시적으로 제어

Spring Security에서 JWK를 가져오는 컴포넌트는 내부적으로 Nimbus 라이브러리를 사용합니다. 운영에서 중요한 포인트는 다음입니다.

  • JWK 캐시 TTL을 너무 길게 두면 키 회전 직후 장애가 길어진다.
  • 너무 짧게 두면 모든 인스턴스가 JWK Set을 자주 조회해 IdP에 부하를 준다.
  • 최적은 “짧은 TTL + 실패 시 즉시 리프레시” 패턴이다.

아래 예시는 NimbusJwtDecoder를 직접 구성해 JWK Set 캐시 동작을 제어하는 방식입니다. (Spring Boot 자동설정보다 한 단계 내려가 커스터마이징)

Spring Security 설정 예시: 캐시/리프레시 제어

import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.jwk.source.RemoteJWKSet;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jose.util.DefaultResourceRetriever;
import com.nimbusds.jose.util.ResourceRetriever;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;

import java.net.URL;

@Configuration
public class JwtDecoderConfig {

    @Bean
    public JwtDecoder jwtDecoder(
            @Value("${security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwkSetUri
    ) throws Exception {

        // 타임아웃을 명시적으로 설정(네트워크 이슈 시 장애 전파 방지)
        int connectTimeoutMs = 1000;
        int readTimeoutMs = 2000;
        int sizeLimitBytes = 1024 * 1024;

        ResourceRetriever retriever = new DefaultResourceRetriever(
                connectTimeoutMs,
                readTimeoutMs,
                sizeLimitBytes
        );

        // RemoteJWKSet은 내부적으로 JWK Set을 캐시한다.
        // (TTL/리프레시 정책은 라이브러리 버전에 따라 옵션이 다르므로,
        //  여기서는 네트워크/타임아웃과 함께 "실패 시 재조회"가 일어나도록 구성을 분리한다.)
        JWKSource<SecurityContext> jwkSource = new RemoteJWKSet<>(new URL(jwkSetUri), retriever);

        return NimbusJwtDecoder.withJwkSource(jwkSource).build();
    }
}

주의할 점은 “TTL을 어디서 조정하느냐”가 Spring 버전 및 Nimbus 버전에 따라 달라질 수 있다는 것입니다. 그래서 운영에서는 다음 두 가지를 함께 권장합니다.

  • (필수) JWK 조회 타임아웃을 짧게 잡아 장애 전파를 줄인다.
  • (권장) 키 회전 정책에 맞춰 JWK 캐시 TTL을 조정하거나, 최소한 “서명 검증 실패 시 강제 리프레시”가 가능한 구조로 만든다.

만약 프레임워크/라이브러리 업그레이드가 가능하다면, JWK 캐시 TTL을 명확히 설정할 수 있는 버전으로 올리는 것도 실전적인 해법입니다.

해결 전략 2: kid 미스매치 시 JWK 강제 리프레시(재조회) 패턴

가장 효과적인 운영 패턴은 다음입니다.

  • 평상시에는 캐시 사용
  • JWT 검증 중 kid를 못 찾거나 서명 검증이 특정 예외로 실패하면
  • JWK Set을 즉시 한 번 재조회 후 재검증

이렇게 하면 “키 회전 직후 첫 요청”에서만 비용이 발생하고, 이후는 정상화됩니다.

아래는 개념 예시입니다. 실제 구현은 사용하는 Nimbus API 버전에 맞게 조정해야 하지만, 핵심은 검증 실패를 트리거로 JWK를 갱신하는 것입니다.

import org.springframework.security.oauth2.jwt.*;

public class RefreshingJwtDecoder implements JwtDecoder {

    private final JwtDecoder primary;
    private final Runnable refreshAction;

    public RefreshingJwtDecoder(JwtDecoder primary, Runnable refreshAction) {
        this.primary = primary;
        this.refreshAction = refreshAction;
    }

    @Override
    public Jwt decode(String token) throws JwtException {
        try {
            return primary.decode(token);
        } catch (JwtException ex) {
            // 여기서 예외 타입/메시지로 "kid not found" / "invalid signature" 등을 선별해도 된다.
            refreshAction.run();
            return primary.decode(token);
        }
    }
}

refreshAction은 내부적으로 JWK 캐시를 비우거나, 새로운 JwtDecoder 인스턴스를 재생성하는 식으로 구현할 수 있습니다. 다만 재생성은 동시성 이슈가 생길 수 있으니 AtomicReference로 디코더를 교체하거나, 갱신 락을 두는 게 안전합니다.

해결 전략 3: 인프라/프록시 캐시로 JWK 응답이 잘못 캐싱되는지 점검

의외로 많은 케이스가 “애플리케이션 캐시”가 아니라 중간 캐시 문제입니다.

  • 사내 프록시가 jwks.json 응답을 과도하게 캐싱
  • CDN이 Cache-Control을 무시하거나 TTL을 강제
  • WAF/게이트웨이가 특정 응답을 오래 저장

점검 방법:

  • 리소스 서버에서 직접 IdP로 나가는지(egress 경로) 확인
  • jwks.json 응답 헤더의 Cache-Control, Age, ETag 확인
  • 동일 URL을 여러 노드에서 조회했을 때 응답이 동일한지 비교

예시:

curl -I "https://issuer.example.com/.well-known/jwks.json"

여기서 Age가 과도하게 크거나, Cache-Control이 기대와 다르면 중간 캐시를 의심해야 합니다.

해결 전략 4: 키 회전 운영 정책 자체를 조정(겹침 기간 확보)

IdP 키 회전은 보통 아래 정책을 권장합니다.

  • 새 키를 JWK Set에 먼저 추가
  • 일정 시간 동안 구 키와 신 키를 함께 제공 (overlap)
  • 토큰 발급은 점진적으로 새 키로 전환
  • 구 키 제거는 토큰 최대 수명(exp)이 지난 뒤

겹침 기간이 없으면, 캐시가 조금만 늦어도 바로 401이 발생합니다. 즉, 애플리케이션에서 캐시를 잘 다뤄도 키 회전 정책이 공격적이면 장애가 날 수 있습니다.

Spring Boot 설정 체크리스트: issuer/audience도 함께 확인

JWK 캐시가 원인인 경우가 많지만, 키 회전과 동시에 다음 설정이 얽혀 401로 보이는 경우도 있습니다.

  • issuer-uri 변경(테넌트/도메인 변경)
  • aud 불일치(클라이언트/리소스 식별자 변경)
  • 알고리즘 변경(RS256에서 ES256 등)

기본 설정 예시:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: "https://issuer.example.com/"
          # 또는 jwk-set-uri를 직접 지정
          # jwk-set-uri: "https://issuer.example.com/.well-known/jwks.json"

운영에서는 issuer-uri를 쓰는 편이 메타데이터(.well-known/openid-configuration)까지 따라가므로 안전한 경우가 많지만, 네트워크 경로가 복잡하거나 메타데이터 조회가 불안정하면 jwk-set-uri를 고정하는 전략도 고려할 수 있습니다.

관측/알림: 401을 "사용자 오류"로 취급하지 말 것

키 회전 캐시 문제는 사용자 요청이 아니라 서버 구성/외부 의존성 문제인 경우가 많습니다. 따라서 관측 포인트를 분리하는 게 중요합니다.

  • 401 비율이 특정 시간대에 급증하면 경보
  • JWT 검증 예외를 별도 메트릭으로 카운트
  • 예외 메시지에서 kid 관련 패턴을 추출해 집계

또한 다중 인스턴스 환경에서는 “어느 노드에서 실패했는지”가 핵심 단서이므로, 로그에 pod name 또는 instance id를 반드시 포함시키세요.

결론: 최적 해법은 "짧은 TTL + 실패 시 즉시 리프레시"

Spring Security JWT 401이 JWK 키 회전 직후에만 간헐적으로 발생한다면, 문제의 본질은 거의 항상 JWK Set 캐시와 키 회전 이벤트의 시간차입니다.

실전 대응 우선순위는 다음이 좋습니다.

  1. 실패 토큰의 kid와 JWK Set의 kid를 비교해 원인을 확정
  2. JWK 조회 타임아웃/네트워크 경로 안정화
  3. 캐시 TTL을 과도하게 길게 두지 않기
  4. 검증 실패 시 JWK 강제 리프레시(재조회) 패턴 도입
  5. IdP 키 회전 정책에 겹침 기간 확보

이 조합이면 키 회전이 있어도 401 스파이크를 크게 줄이고, “자연 회복을 기다리는” 운영에서 벗어날 수 있습니다.