Published on

Spring Security OAuth2 401 - JWKS 캐시·kid 불일치 해결

Authors

서버는 살아 있고 토큰도 방금 발급받았는데, 어떤 요청은 200이고 어떤 요청은 401. 특히 배포 직후나 IdP(Authorization Server) 키 로테이션 이후에만 간헐적으로 터진다면, 대부분 원인은 JWKS(JSON Web Key Set) 캐시kid(Key ID) 불일치입니다. Spring Security OAuth2 Resource Server는 기본적으로 NimbusJwtDecoder를 통해 JWKS를 받아 캐시하고, 토큰 헤더의 kid에 해당하는 공개키로 서명을 검증합니다. 여기서 캐시가 오래되었거나(또는 인스턴스마다 캐시 상태가 다르거나) IdP가 새 키로 서명하기 시작했는데 리소스 서버가 아직 옛 JWKS를 들고 있으면 kid를 찾지 못해 401이 발생합니다.

이 글에서는 다음을 다룹니다.

  • 401이 kid 불일치인지 issuer/audience/clock skew인지 빠르게 구분하는 법
  • Spring Security의 JWKS 로딩/캐시 동작과 장애 패턴
  • 키 로테이션에 강한 설정(캐시, 타임아웃, 재시도, 멀티 이슈어)
  • 운영에서 재현/진단/완화 체크리스트

> 네트워크/프록시 계층 이슈로 JWKS 호출이 실패해도 비슷한 증상이 납니다. 인그레스/프록시 관점의 타임아웃·연결 종료 진단은 Cloudflare 520·521, Nginx·ALB 로그로 30분 진단도 함께 참고하면 좋습니다.

증상 패턴: “간헐적 401”이 말해주는 것

다음 패턴이면 JWKS 캐시/키 로테이션 가능성이 높습니다.

  • 동일 토큰이 어떤 인스턴스에서는 200, 다른 인스턴스에서는 401
  • 키 로테이션 직후 5~30분 동안만 401이 증가했다가 자연 회복
  • 재시도하면 성공(혹은 일정 시간 후 성공)
  • 로그에 kid를 못 찾는 메시지 또는 JWKS fetch 실패가 섞여 있음

반대로 아래면 다른 원인을 의심합니다.

  • 항상 401: issuer/audience 설정 불일치, 잘못된 JWK URI, 잘못된 서명 알고리즘
  • 특정 사용자/클라이언트만 401: audience(scope) 정책 또는 커스텀 claim 검증
  • 특정 시간대만 401: 서버 시간 오프셋(NTP), exp/nbf 검증

로그로 원인 좁히기: kid 불일치 vs 다른 검증 실패

Spring Security는 JWT 검증 실패를 BearerTokenAuthenticationEntryPoint에서 401로 응답하며, 내부적으로는 JwtException 계열이 발생합니다. 운영에서 가장 빠른 방법은 디코더 레벨의 예외 메시지를 확보하는 것입니다.

1) Spring Security 디버그 로그 켜기

# application.yml
logging:
  level:
    org.springframework.security: DEBUG
    org.springframework.security.oauth2: DEBUG

여기서 다음과 같은 메시지가 보이면 키 관련 이슈로 좁혀집니다.

  • Signed JWT rejected: Another algorithm expected, or no matching key(s) found
  • No matching key(s) found / kid not found
  • Couldn't retrieve remote JWK set (네트워크/타임아웃/프록시)

2) 토큰 헤더의 kid 확인

토큰을 디코드해서 헤더의 kid, alg를 확인합니다.

TOKEN="eyJ..."
python - <<'PY'
import base64, json, os

t = os.environ['TOKEN'].split('.')
header = json.loads(base64.urlsafe_b64decode(t[0] + '=='))
print(json.dumps(header, indent=2))
PY
  • kid가 있는데 JWKS에 없다 → 키 로테이션/캐시 가능성 큼
  • algRS256인데 서버가 ES256 기대(혹은 반대) → 설정/IdP 정책 불일치

3) JWKS에 해당 kid가 존재하는지 확인

JWKS_URI="https://issuer.example.com/.well-known/jwks.json"
KID="abc123"

curl -s "$JWKS_URI" | jq -r --arg kid "$KID" '.keys[] | select(.kid==$kid) | {kid,kty,alg,use}'
  • 지금 JWKS에는 있는데 서버는 못 찾는다 → 서버 캐시가 오래됨/갱신 실패
  • 지금 JWKS에도 없다 → IdP가 토큰 서명에 사용한 키를 JWKS에 아직 게시하지 않았거나, 잘못된 issuer의 JWKS를 보고 있음

왜 Spring Resource Server에서 kid 불일치가 생기나

1) 키 로테이션의 “전파 지연”

IdP는 보통 다음 순서로 로테이션합니다.

  1. 새 키 생성
  2. JWKS에 새 공개키 게시
  3. 새 키로 토큰 서명 시작
  4. 구 키는 일정 기간 유지 후 제거

이상적으로는 2→3 순서가 보장되어야 하지만, 실제로는 캐시/CDN/다중 리전 전파 지연 때문에 3이 먼저 체감되는 경우가 있습니다. 리소스 서버가 JWKS를 갱신하기 전까지는 새 kid를 검증할 수 없어 401이 납니다.

2) 리소스 서버 인스턴스별 JWKS 캐시 불일치

Spring의 NimbusJwtDecoder는 내부적으로 원격 JWKS를 가져와 캐시합니다. 캐시 갱신 타이밍은 인스턴스마다 다를 수 있어, 로드밸런서 뒤에서 어떤 Pod는 성공, 어떤 Pod는 실패가 됩니다.

3) JWKS fetch 자체가 불안정

다음이 있으면 갱신이 제때 일어나지 않습니다.

  • 프록시/게이트웨이에서 JWKS 엔드포인트로의 egress 타임아웃
  • DNS 캐시/네임서버 문제
  • IdP 측 rate limit/간헐 오류

EKS에서 네트워크 플러그인/노드 상태가 불안정하면 이런 증상이 증폭됩니다. 인프라 레벨에서 Pod 네트워크 상태도 함께 점검하세요: EKS Pod NotReady(NetworkPlugin cni) 10분 해결

해결 전략 1: “kid 불일치”에 강한 JWKS 캐시/갱신 설계

핵심은 키 로테이션 시점에 빠르게 JWKS를 재조회하고, 네트워크 실패를 견딜 수 있게 만드는 것입니다.

1) Spring Boot 기본 설정 점검 (issuer-uri 권장)

가능하면 jwk-set-uri를 직접 박기보다 issuer-uri를 쓰는 편이 안전합니다(Well-known 메타데이터 기반).

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://issuer.example.com/
  • issuer가 바뀌었는데 예전 issuer의 JWKS를 보고 있으면 kid가 영원히 안 맞습니다.
  • 멀티 테넌트/멀티 이슈어면 AuthenticationManagerResolver가 필요합니다(아래 참고).

2) 타임아웃/프록시 설정을 명시해 JWKS fetch 실패 줄이기

Spring Security는 JWKS를 가져올 때 HTTP 클라이언트를 사용합니다. 운영 환경에서는 타임아웃을 명시하지 않으면 장애 시 지연이 길어져 스레드가 묶이거나, 반대로 너무 짧아 잦은 실패가 날 수 있습니다.

아래는 NimbusJwtDecoder + 커스텀 RestOperations로 타임아웃을 명시하는 예시입니다.

@Configuration
public class SecurityConfig {

    @Bean
    JwtDecoder jwtDecoder(RestTemplateBuilder builder) {
        RestOperations rest = builder
                .setConnectTimeout(Duration.ofSeconds(2))
                .setReadTimeout(Duration.ofSeconds(2))
                .build();

        String jwkSetUri = "https://issuer.example.com/.well-known/jwks.json";

        return NimbusJwtDecoder.withJwkSetUri(jwkSetUri)
                .restOperations(rest)
                .build();
    }
}
  • JWKS 호출이 느려지면 인증 자체가 느려지고, 트래픽이 많을수록 병목이 됩니다.
  • egress 프록시가 있다면 RestTemplate에 프록시 설정도 반영하세요.

3) kid 미스 시 “즉시 재조회”가 되는지 확인

Nimbus는 일반적으로 kid가 없으면 캐시를 갱신해 다시 시도하는 동작이 있지만, 환경/버전/실패 조건에 따라 기대만큼 빨리 회복되지 않을 수 있습니다.

운영에서 가장 확실한 완화책은 키 로테이션 이벤트가 발생할 때 리소스 서버의 JWKS 캐시를 강제로 갱신하는 것입니다.

  • 배포 파이프라인에서 /actuator/refresh 같은 갱신 훅을 호출(Cloud Config 사용 시)
  • 애플리케이션 내부에서 주기적으로 JWKS를 워밍업(아래 예시)

JWKS 워밍업(헬스체크/스케줄) 예시

@Component
public class JwksWarmup {
    private final JwtDecoder jwtDecoder;

    public JwksWarmup(JwtDecoder jwtDecoder) {
        this.jwtDecoder = jwtDecoder;
    }

    // 5분마다 "가짜" 토큰을 넣는 방식은 비추천(로그/알람 오염)
    // 대신 JWKS URI를 직접 호출해 캐시/네트워크를 예열하는 방식을 권장
}

실무적으로는 JWKS URI를 직접 GET해서 캐시 계층(DNS, 프록시, NAT)을 예열하는 편이 낫습니다. (디코더에 가짜 토큰을 넣으면 실패가 정상인데도 보안 로그가 지저분해집니다.)

해결 전략 2: 키 로테이션 운영 원칙(가장 효과 큼)

애플리케이션 튜닝보다 더 중요한 건 IdP 로테이션 정책입니다.

1) “새 키 게시 → 충분히 대기 → 서명 전환”

  • JWKS에 새 키를 게시하고 최소 수 분~수십 분(전파/캐시 고려) 대기
  • 그 후에 서명 키를 새 키로 전환
  • 구 키는 토큰 최대 수명(exp) + 여유 기간 동안 유지

이 원칙이 깨지면, 리소스 서버가 아무리 잘해도 전환 순간 401이 발생할 수 있습니다.

2) 캐시 헤더(Cache-Control) 확인

IdP가 JWKS 응답에 과도한 캐시 TTL을 걸면, 중간 CDN/프록시가 오래된 JWKS를 계속 내줄 수 있습니다.

curl -I "https://issuer.example.com/.well-known/jwks.json"
  • Cache-Control: max-age=...가 너무 크면 조정 고려
  • CDN이 있다면 퍼지 전략(키 로테이션 시 purge)도 준비

해결 전략 3: 멀티 이슈어/멀티 테넌트에서의 kid 혼선 제거

멀티 테넌트에서 흔한 실수는 issuer A 토큰을 issuer B의 JWKS로 검증하려다 401이 나는 것입니다. 이 경우 메시지가 kid 불일치처럼 보여도, 근본 원인은 라우팅입니다.

Spring Security에서는 JwtIssuerAuthenticationManagerResolver로 issuer별 디코더를 분리할 수 있습니다.

@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
    var resolver = new JwtIssuerAuthenticationManagerResolver(
            "https://issuer-a.example.com/",
            "https://issuer-b.example.com/"
    );

    http
      .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
      .oauth2ResourceServer(oauth2 -> oauth2.authenticationManagerResolver(resolver));

    return http.build();
}
  • issuer별로 JWKS 캐시가 분리되어 kid 충돌/혼선을 줄입니다.
  • 단, issuer 목록이 동적으로 늘어나는 SaaS라면 커스텀 resolver가 필요합니다.

재현과 진단: 로테이션 직후 401을 잡는 체크리스트

1) 401 응답 바디/헤더 확인

기본 설정에서는 WWW-Authenticate: Bearer error="invalid_token" 정도만 내려가 원인 파악이 어렵습니다. 서버 로그에 예외를 남기거나, 관측 가능성을 위해 메트릭을 추가하세요.

  • jwt_decode_failure_total{reason=...} 같은 카운터
  • jwks_fetch_failure_total

2) 인스턴스별로 같은 토큰을 호출해 차이 확인

로드밸런서가 있다면 특정 Pod로 고정해서 비교합니다.

  • (Kubernetes) kubectl port-forward로 Pod에 직접 호출
  • (Ingress) sticky session/헤더 라우팅으로 특정 인스턴스 유도

인스턴스별로 결과가 다르면 거의 확실히 캐시/JWKS 갱신 타이밍 문제입니다.

3) JWKS 엔드포인트 접근성 점검

리소스 서버가 실제로 JWKS를 가져올 수 있는지 확인합니다.

# Pod 내부에서
curl -v --max-time 2 https://issuer.example.com/.well-known/jwks.json
nslookup issuer.example.com
  • DNS 지연, TLS 핸드셰이크 실패, 프록시 차단이 있는지 확인
  • 서비스 메시(Envoy) 환경이면 egress 정책/클러스터 설정도 확인

실전 권장 구성: 안정적인 Resource Server 템플릿

아래는 운영에서 많이 쓰는 형태의 “안전한 기본값”입니다.

  • issuer-uri 기반
  • JWKS fetch 타임아웃 명시
  • 적절한 clock skew(필요 시)
  • audience 검증(선택)
@Configuration
public class ResourceServerConfig {

    @Bean
    JwtDecoder jwtDecoder(RestTemplateBuilder builder) {
        RestOperations rest = builder
                .setConnectTimeout(Duration.ofSeconds(2))
                .setReadTimeout(Duration.ofSeconds(2))
                .build();

        String jwkSetUri = "https://issuer.example.com/.well-known/jwks.json";

        NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri)
                .restOperations(rest)
                .build();

        // (선택) issuer/audience/clock skew 검증을 명시적으로 구성할 수 있음
        // 기본 issuer 검증은 issuer-uri 구성에서 자동으로 들어가는 편이 일반적
        return decoder;
    }

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
          .authorizeHttpRequests(auth -> auth
              .requestMatchers("/actuator/health").permitAll()
              .anyRequest().authenticated()
          )
          .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));

        return http.build();
    }
}

> 참고: audience 검증은 서비스마다 다릅니다. 토큰이 여러 API에 공용으로 쓰이는 구조라면 audience를 강제하면 오히려 장애가 될 수 있으니, IdP 정책과 합의된 경우에만 적용하세요.

운영 팁: “401 폭증”을 장애로 키우지 않는 방법

  • 키 로테이션 알림을 받는 즉시(또는 예정 시간에) 리소스 서버를 롤링 재시작/캐시 워밍업
  • JWKS 엔드포인트를 내부망에서 접근한다면, TLS/SNI 문제가 없는지 점검(인증서 교체 시 자주 터짐): EKS Pod간 TLS 실패? 인증서·SNI·mTLS 10분 진단
  • 401이 실제로는 “인증 실패”가 아니라 “JWKS fetch 실패”인 경우가 많으니, JWKS fetch 실패율을 별도 메트릭으로 분리
  • 로드밸런서 뒤 다중 인스턴스라면, 특정 인스턴스에서만 401이 나는지 즉시 확인(캐시 편차)

마무리: 결론은 ‘kid’가 아니라 ‘전파’다

Spring Security OAuth2에서 401이 간헐적으로 발생하고 로그에 kid 미스/매칭 실패가 보인다면, 토큰 자체가 나쁜 게 아니라 키 로테이션과 JWKS 캐시 전파가 어긋난 경우가 대부분입니다. 해결은 크게 두 축입니다.

  1. IdP 로테이션 원칙 준수(새 키 게시 → 대기 → 서명 전환 → 구 키 유지)
  2. 리소스 서버의 JWKS 갱신/네트워크 안정화(타임아웃/프록시/워밍업/인스턴스 편차 제거)

이 두 가지를 잡으면, “재시도하면 되는” 수준의 간헐적 401이 아니라, 장애로 번지기 쉬운 인증 불안정성을 근본적으로 줄일 수 있습니다.