Published on

Spring Security JWT 401 - JWK 캐시와 kid 불일치 해결

Authors

운영 중인 Spring Security(Resource Server)에서 갑자기 JWT 인증이 401 Unauthorized로 터지는데, 토큰 자체는 정상이고 IdP(예: Keycloak, Auth0, Cognito, 사내 OIDC)도 살아있는 상황이 있습니다. 이때 로그를 보면 대개 kid(Key ID) 관련 메시지나 JWK(JSON Web Key) 로딩/캐시 문제로 귀결됩니다.

이 글은 다음 상황을 목표로 합니다.

  • 간헐적으로만 401이 발생한다
  • 재시작하면 잠깐 정상인데 다시 터진다
  • 키 롤오버(회전) 직후부터 문제가 시작됐다
  • 멀티 인스턴스/멀티 리전 환경에서 특정 파드/인스턴스만 실패한다

참고로, 인증 전반의 401 원인을 더 넓게 훑고 싶다면 Kubernetes 401 Unauthorized 원인별 해결 가이드도 함께 보시면 네트워크/프록시/헤더 레벨까지 연결해서 점검하기 좋습니다.

1) 증상 패턴: kid를 못 찾는 401

JWT 헤더에는 보통 kid가 들어 있습니다. 리소스 서버는 이 kid에 해당하는 공개키를 JWK Set(jwks_uri)에서 찾아 서명을 검증합니다.

문제는 다음 중 하나가 깨졌을 때 발생합니다.

  • 토큰 헤더의 kid가 JWK Set에 없다(키 롤오버 타이밍, 배포 타이밍 불일치)
  • 리소스 서버가 예전 JWK Set을 캐시하고 있어 새 키를 못 본다(캐시 TTL, 캐시 무효화 문제)
  • JWK Set 호출이 간헐적으로 실패해 캐시 갱신이 못 된다(네트워크, 타임아웃, 프록시)
  • issuer/uri 설정이 잘못되어 다른 테넌트의 JWK를 보고 있다(환경 변수/설정 오류)

대표 로그 예시

환경마다 문구는 다르지만, 보통 아래 계열입니다.

  • JwtException: Failed to validate JWT
  • No matching key(s) found
  • Cannot find a key with kid=...
  • Unable to resolve the JWK key set

이런 경우, 토큰이 만료되었거나 클레임이 틀린 문제라기보다 “서명 검증용 키를 못 구했다”에 가깝습니다.

2) 원인 1: 키 롤오버와 JWK 전파 지연

IdP는 키를 회전시키며, 새 private key로 토큰을 서명하기 시작합니다. 동시에 공개키는 JWK Set에 새 항목으로 추가됩니다.

여기서 레이스가 생길 수 있습니다.

  1. IdP A 노드가 새 키로 토큰을 발급(새 kid)
  2. 리소스 서버는 JWK Set을 가져오지만, 아직 캐시/전파가 덜 되어 JWK Set에 새 kid가 없다
  3. 서명 검증 실패로 401

특히 IdP가 멀티 노드/멀티 리전이면 “발급 노드”와 “JWK 제공 노드”의 동기화가 늦어 간헐적 401이 더 흔합니다.

빠른 확인 방법

  • 실패한 요청의 JWT를 복사해 헤더 kid를 확인
  • 현재 jwks_uri를 직접 호출해 그 kid가 있는지 확인

아래는 로컬에서 JWT 헤더만 보는 예시입니다.

TOKEN="eyJ..."
echo "$TOKEN" | cut -d'.' -f1 | base64 -d 2>/dev/null | jq

kid가 보이면, JWK Set에서 동일 kid가 존재하는지 확인합니다.

curl -s https://issuer.example.com/.well-known/jwks.json | jq '.keys[].kid'

여기서 kid가 없다면, 리소스 서버 문제가 아니라 IdP 키 배포/전파 타이밍 문제일 가능성이 큽니다.

3) 원인 2: Spring Security의 JWK 캐시와 갱신 타이밍

Spring Boot 3 + Spring Security 6 기준으로 리소스 서버는 내부적으로 JWK Set을 가져와 캐시합니다. 기본 동작은 “매 요청마다 JWK를 다시 가져오기”가 아니라 “필요할 때 가져오고 캐시한다”에 가깝습니다.

따라서 다음 상황에서 문제가 커집니다.

  • JWK 캐시 TTL이 길다(혹은 사실상 무기한처럼 동작)
  • JWK 호출이 일시 실패하면, 갱신이 지연된다
  • 여러 인스턴스가 각자 다른 시점에 캐시를 잡아, 어떤 인스턴스만 실패한다

실전 팁: 캐시 문제를 의심해야 하는 시그널

  • 파드 A는 계속 401, 파드 B는 정상
  • 파드 A를 재시작하면 즉시 정상
  • 일정 시간이 지나면 다시 401

이 패턴은 “인스턴스 로컬 캐시가 꼬였다”에 가깝습니다.

4) 해결 전략: 우선순위 체크리스트

4-1. issuer와 jwks_uri가 환경별로 정확한지

가장 흔한 설정 실수는 “issuer는 맞는데 jwks_uri가 다른 테넌트/다른 realm”을 가리키는 경우입니다.

Spring Boot에서는 보통 아래 둘 중 하나로 설정합니다.

  • spring.security.oauth2.resourceserver.jwt.issuer-uri
  • spring.security.oauth2.resourceserver.jwt.jwk-set-uri

가능하면 issuer-uri를 우선 사용하고, jwk-set-uri는 예외적으로만 쓰는 편이 안전합니다(issuer 기반으로 메타데이터를 따라가면 환경 간 실수 여지가 줄어듭니다).

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://issuer.example.com/

4-2. JWK 엔드포인트 응답 헤더(Cache-Control) 확인

IdP가 jwks.json에 강한 캐싱 헤더를 붙이면, 중간 프록시/CDN/클라이언트가 오래 캐시할 수 있습니다.

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

여기서 Cache-Control: max-age=...가 과도하게 크면, 키 롤오버 시 401이 길게 지속될 수 있습니다.

  • IdP 설정에서 JWK 캐시 TTL을 짧게
  • 또는 롤오버 시 “old key를 일정 기간 유지”하여 새 토큰과 구 토큰을 동시에 검증 가능하게

이 “old key 유지 기간”은 운영 안정성에 매우 중요합니다.

4-3. 네트워크/타임아웃/프록시로 JWK 갱신이 실패하지 않는지

JWK 갱신은 외부 HTTP 호출입니다. 따라서 다음이 숨어 있을 수 있습니다.

  • DNS 간헐 오류
  • egress 방화벽/보안그룹 문제
  • 프록시가 특정 경로를 차단
  • 타임아웃이 짧아 간헐 실패

이 경우 애플리케이션 로그에 “JWK Set을 못 가져왔다”류의 예외가 같이 남습니다.

네트워크 계층 문제는 401로만 보이지만, 본질은 외부 호출 실패인 경우가 많습니다. (HTTP 클라이언트 레벨 트러블슈팅 관점은 Python httpx RemoteProtocolError 서버 끊김 원인과 해결처럼 “원인은 네트워크인데 증상은 상위 레벨 오류”인 전형적인 패턴과 유사합니다.)

5) Spring Security에서 JWK 로딩/캐시를 제어하는 방법

Spring Security의 기본 구성만으로도 동작하지만, 운영에서 401을 줄이려면 다음을 고려할 수 있습니다.

  • RestOperations(또는 WebClient) 커스터마이징으로 타임아웃/프록시 설정
  • JWK Set을 가져오는 컴포넌트에 캐시 정책을 명시
  • 키 롤오버 직후 강제 리프레시(운영 도구/관리 엔드포인트)

아래 예시는 Spring Boot 3 환경에서 NimbusJwtDecoder를 직접 구성하면서, JWK 호출용 HTTP 클라이언트를 튜닝하는 방식입니다.

5-1. 타임아웃이 있는 RestTemplate 기반 Decoder 구성

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.web.client.RestOperations;
import org.springframework.web.client.RestTemplate;

@Configuration
public class JwtDecoderConfig {

  @Bean
  public RestOperations jwkRestOperations() {
    SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
    factory.setConnectTimeout(2_000);
    factory.setReadTimeout(2_000);

    return new RestTemplate(factory);
  }

  @Bean
  public JwtDecoder jwtDecoder(RestOperations jwkRestOperations) {
    String jwkSetUri = "https://issuer.example.com/.well-known/jwks.json";

    return NimbusJwtDecoder
        .withJwkSetUri(jwkSetUri)
        .restOperations(jwkRestOperations)
        .build();
  }
}

이 구성의 장점은 “JWK 호출이 느려서 스레드가 묶이고, 그 여파로 인증 실패가 늘어나는 상황”을 줄이는 데 있습니다. (가상 스레드/스레드 고갈 이슈까지 같이 있다면 Spring Boot 3 가상스레드 도입 후 Deadlock·TPS 저하 진단처럼 병목을 함께 점검하는 것이 좋습니다.)

5-2. issuer-uri를 쓰면서도 HTTP 튜닝이 필요할 때

issuer-uri를 사용하면 내부적으로 메타데이터를 읽고 JWK Set URI를 따라갑니다. 이때도 결국 HTTP 호출이므로 타임아웃/프록시 튜닝이 필요할 수 있습니다.

운영에서는 다음을 권합니다.

  • connect timeout, read timeout을 명시
  • JWK 엔드포인트에 대한 egress 경로를 별도 모니터링(성공률, latency)

6) kid 불일치의 또 다른 원인: 토큰이 “다른 issuer”에서 발급됨

간헐적 401인데, 특정 사용자/특정 클라이언트에서만 발생한다면 다음도 의심해야 합니다.

  • 모바일 앱이 예전 환경(스테이징 issuer) 토큰을 들고 온다
  • 멀티 테넌트에서 테넌트 A 토큰이 테넌트 B API로 들어온다
  • API Gateway가 Authorization 헤더를 재작성/혼합한다

이 경우 JWK 캐시를 아무리 만져도 해결이 안 됩니다. iss(issuer) 클레임을 로깅해 “어디서 발급된 토큰인지”를 먼저 확정하세요.

Spring Security에서 인증 실패 시 토큰 클레임을 그대로 로그로 남기면 보안 이슈가 생길 수 있으니, 최소한 iss, aud, kid 정도만 마스킹/선별 로깅하는 방식이 안전합니다.

7) 운영에서 401을 줄이는 설계 팁

7-1. 키 롤오버 정책: 새 키 추가 후 일정 기간 old key 유지

가장 효과적인 방법입니다.

  • 시점 t0: JWK Set에 새 공개키 추가(새 kid 노출)
  • 시점 t1: 새 private key로 서명 시작(토큰 발급 전환)
  • 시점 t2: 충분한 유예기간 후 old key 제거

t0t1 순서가 뒤집히면, 새 kid 토큰이 먼저 나가고 JWK Set에 키가 없어 401이 터집니다.

7-2. 인스턴스 재시작으로만 해결되는 구조를 없애기

재시작이 해결책처럼 보이면, 사실은 캐시/갱신/네트워크 문제를 가리고 있는 것입니다.

  • JWK 엔드포인트 호출 실패율을 메트릭으로 수집
  • 401 비율을 인스턴스별로 분해
  • 실패한 kid 목록을 집계

이렇게 하면 “특정 kid에서만 실패”인지 “특정 인스턴스에서만 실패”인지 빠르게 갈라집니다.

7-3. 장애 시나리오: JWK 엔드포인트 장애가 곧 인증 장애

JWK는 인증의 핵심 의존성입니다. 외부 IdP를 쓰면, 네트워크 단절이나 IdP 장애가 바로 401 폭증으로 이어질 수 있습니다.

  • 멀티 리전 IdP 엔드포인트
  • 내부 캐시(단, TTL/무효화 전략 포함)
  • 장애 시 빠르게 원인 분리 가능한 관측(로그/메트릭)

이 3가지를 갖추면 “원인은 JWK인데 증상은 401”인 사건을 훨씬 빨리 수습할 수 있습니다.

8) 문제 재현과 검증: 최소 테스트 시나리오

다음 시나리오로 재현하면 원인 파악이 빨라집니다.

  1. IdP에서 키를 롤오버(새 kid 생성)
  2. 새 토큰을 발급받아 API 호출
  3. 동시에 jwks.json에서 새 kid가 노출되는지 확인
  4. 리소스 서버 인스턴스별로 성공/실패를 분리

간단한 호출 테스트는 아래처럼 할 수 있습니다.

curl -i \
  -H "Authorization: Bearer $TOKEN" \
  https://api.example.com/v1/me

그리고 서버 측에서는 인증 실패 시 다음을 남기면 좋습니다.

  • 요청 ID
  • iss(가능하면)
  • JWT 헤더의 kid
  • JWK 갱신 시도 여부와 결과(성공/실패, latency)

마무리

Spring Security JWT 401은 표면적으로는 “인증 실패”지만, 운영에서 자주 마주치는 핵심 원인은 kid와 JWK Set 간의 불일치, 그리고 JWK 캐시/갱신 타이밍 문제입니다.

정리하면 우선순위는 다음과 같습니다.

  1. 실패 토큰의 kid가 현재 JWK Set에 존재하는지 확인
  2. issuer-urijwks_uri가 환경/테넌트에 맞는지 확인
  3. JWK 엔드포인트의 캐시 헤더와 키 롤오버 정책(old key 유지)을 점검
  4. 리소스 서버의 JWK HTTP 호출 타임아웃/네트워크 안정성을 확보
  5. 인스턴스별 401 편차가 있는지 관측해 캐시 문제를 특정

이 흐름대로 보면 “재시작하면 고쳐지는 401”에서 벗어나, 재현 가능하고 설계로 예방 가능한 문제로 바꿀 수 있습니다.