Published on

Spring Boot 3 JWT 401 - JWK 캐시·키회전 완전 정복

Authors

서버가 멀쩡한데 특정 시점부터 JWT가 갑자기 401(Unauthorized)로 떨어지는 경우가 있습니다. 특히 OAuth2 Resource Server + JWT(JWK Set URI) 구성에서, 인증 서버(IdP)가 키를 회전(rotation)했거나 JWK 캐시가 예상과 다르게 동작하면 “어제까지 되던 토큰이 오늘은 안 됨” 같은 현상이 나타납니다.

이 글은 Spring Boot 3(=Spring Security 6) 기준으로 JWK 캐시/키회전 때문에 발생하는 401의 원인을 짚고, 재현 → 로그로 확인 → 설정/코드로 해결까지 한 번에 정리합니다. (SecurityContext 자체가 누락되는 케이스는 다른 문제이므로, 그 패턴은 Spring Boot 3에서 가끔 401? SecurityContext 누락 해결 글을 참고하세요.)

401이 “간헐적”으로 보이는 이유

JWT 검증은 보통 다음 흐름입니다.

  1. 클라이언트가 Bearer 토큰(JWT)을 보냄
  2. 리소스 서버가 JWT 헤더의 kid(Key ID)를 확인
  3. jwk-set-uri에서 JWK 세트를 가져와 kid에 해당하는 공개키로 서명 검증
  4. 성공하면 Authentication 생성, 실패하면 401

여기서 간헐적이 되는 대표 원인은 다음 두 가지입니다.

  • 키 회전 직후: IdP가 새로운 키를 발급하고 kid가 바뀌었는데, 리소스 서버가 아직 이전 JWK를 캐시하고 있어 새 kid를 찾지 못함
  • 멀티 인스턴스/콜드스타트: 인스턴스 A는 JWK를 갱신해서 성공, 인스턴스 B는 오래된 캐시라 실패 → 트래픽 분산 시 “가끔” 401

특히 Cloud Run/EKS 같은 환경에서 scale-out/scale-in이 반복되면 이런 체감이 더 강해집니다. (콜드스타트/인스턴스 교체가 잦다면 GCP Cloud Run 503와 콜드스타트 지연 원인·튜닝도 함께 보면 운영 관점에서 도움이 됩니다.)

증상별 빠른 체크리스트

1) 에러가 kid 관련인지 확인

가장 흔한 메시지 패턴은 다음 중 하나입니다.

  • JwtException: An error occurred while attempting to decode the Jwt
  • JwtValidationException: ...
  • No matching key(s) found (또는 “kid에 해당하는 키를 찾을 수 없음”)

즉, 토큰 자체가 만료되었거나 aud/iss가 틀린 게 아니라 “서명 검증에 필요한 공개키를 못 찾는” 상황이 많습니다.

2) 토큰 헤더의 kid 확인

JWT는 헤더에 kid가 들어갑니다. 운영 중 빠르게 확인하려면(민감정보 주의) 헤더만 디코딩하세요.

TOKEN="eyJhbGciOiJSUzI1NiIsImtpZCI6IjEyMyJ9.eyJzdWIiOiJ1c2VyIn0.signature"

# header만 확인 (첫 번째 점(.) 앞 부분)
echo "$TOKEN" | cut -d '.' -f 1 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .

여기서 나온 kid현재 JWK Set에 존재하는지가 핵심입니다.

3) JWK Set을 직접 조회해 kid 매칭

JWK_URI="https://idp.example.com/.well-known/jwks.json"
curl -s "$JWK_URI" | jq '.keys[].kid'
  • 토큰의 kid가 목록에 없다면: IdP가 키를 회전했거나, 배포/캐시 문제로 JWK가 최신이 아님
  • 목록에 있는데도 실패한다면: 네트워크/프록시/SSL/타임아웃/캐시 오염 가능성

Spring Boot 3 기본 구성과 캐시의 함정

Spring Boot 3에서 보통 다음처럼 설정합니다.

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://idp.example.com/
          # 또는 jwk-set-uri: https://idp.example.com/.well-known/jwks.json

issuer-uri를 쓰면 Spring이 OIDC Discovery를 통해 jwks_uri를 찾아옵니다.

문제는 여기서 “Spring이 알아서 JWK를 갱신해주겠지”라는 기대가 키 회전 타이밍과 맞물리면 깨질 수 있다는 점입니다.

  • JWK는 매 요청마다 가져오지 않습니다(성능/가용성 이유)
  • 내부적으로 JWK를 캐시하며, 네트워크 실패 시 캐시를 더 오래 들고 갈 수도 있습니다
  • IdP가 키를 회전할 때 이전 키를 한동안 같이 제공하지 않으면(혹은 제공 시간이 짧으면) 검증 공백이 생깁니다

즉, 리소스 서버만 손봐도 해결되는 문제가 있고, IdP 키 회전 정책을 같이 조정해야 근본 해결이 되는 경우도 많습니다.

재현 시나리오: 키 회전으로 401 만들기

운영에서 일어나던 일을 로컬에서 재현하면 원인 파악이 빨라집니다.

  1. IdP가 JWK Set에 키 A(kid=a)만 제공
  2. 리소스 서버가 JWK를 가져와 캐시
  3. IdP가 키를 회전해 키 B(kid=b)로 서명하기 시작
  4. JWK Set에서 키 A를 즉시 제거(또는 너무 빨리 제거)
  5. 리소스 서버는 여전히 캐시에 키 A만 있어 kid=b 토큰을 검증 못 함 → 401

여기서 포인트는 “리소스 서버가 JWK를 언제 다시 가져오느냐”입니다.

해결 1: IdP 키 회전 정책(가장 확실)

리소스 서버에서 캐시를 아무리 잘 다뤄도, IdP가 다음을 지키지 않으면 공백이 생길 수 있습니다.

  • 새 키로 서명하기 시작해도, 이전 공개키를 JWK Set에서 일정 기간 유지
  • 유지 기간은 “최대 토큰 유효기간 + 캐시/전파 지연”을 커버해야 함
    • 예: access token 15분 + 캐시 10분 + 배포 지연 5분 → 최소 30분 이상 공존 권장

현실적으로는 1~24시간 공존을 두는 곳도 많습니다(보안 정책/토큰 TTL에 따라 다름).

해결 2: Spring Security에서 JWK 캐시/타임아웃을 명시적으로 제어

Spring Boot 3는 내부적으로 Nimbus 기반 디코더를 사용합니다. 운영에서는 다음을 명확히 하는 게 좋습니다.

  • JWK 조회 타임아웃
  • JWK 캐시 TTL(너무 길면 회전 대응이 느림, 너무 짧으면 IdP 부하 증가)
  • 키를 못 찾았을 때 재조회 동작

아래는 NimbusJwtDecoder를 직접 구성해 JWK Set 캐시를 제어하는 예시입니다(프로젝트 상황에 맞게 조정).

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 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
    JwtDecoder jwtDecoder() throws Exception {
        // (1) JWK 조회 타임아웃/사이즈 제한
        var retriever = new DefaultResourceRetriever(
                2000,  // connect timeout ms
                2000,  // read timeout ms
                1024 * 1024 // max size
        );

        // (2) 원격 JWK 소스 (내부 캐시 포함)
        JWKSource<SecurityContext> jwkSource = new RemoteJWKSet<>(
                new URL("https://idp.example.com/.well-known/jwks.json"),
                retriever
        );

        // (3) Nimbus 디코더 생성
        return NimbusJwtDecoder.withJwkSource(jwkSource).build();
    }
}

이 구성만으로도 “JWK 조회가 느려서 실패 → 401” 류는 줄어듭니다. 다만 캐시 TTL/무효화 정책은 라이브러리/버전에 따라 노출 수준이 다를 수 있어, 다음 섹션의 “키 회전 시 재시도 전략”을 함께 고려하는 게 안전합니다.

해결 3: kid 미스매치 시 JWK 강제 리프레시(재시도) 전략

가장 치명적인 순간은 “새 kid 토큰이 들어왔는데, 캐시에 그 키가 없는 경우”입니다. 이때는 다음 전략이 효과적입니다.

  • 첫 검증 실패가 키 미존재/서명키 탐색 실패 유형이면
  • JWK를 한 번 강제로 다시 가져오고
  • 동일 토큰 검증을 1회만 재시도

이 방식은 키 회전 순간의 401을 크게 줄이면서도, 무한 재시도/부하 폭증을 막을 수 있습니다.

Spring Security에서 이를 우아하게 넣는 방법은 여러 가지가 있지만, 실무적으로는 다음 중 하나를 선택합니다.

  1. API Gateway/WAF 레벨에서 401 재시도(권장도는 환경에 따라 다름)
  2. 리소스 서버에서 커스텀 JwtDecoder로 1회 재시도 래핑

아래는 “디코딩 실패 시 1회 재시도” 래퍼 예시입니다(실패 유형 필터링은 로그로 패턴을 확인해 적용하세요).

import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtException;

public class RetryingJwtDecoder implements JwtDecoder {
    private final JwtDecoder delegate;

    public RetryingJwtDecoder(JwtDecoder delegate) {
        this.delegate = delegate;
    }

    @Override
    public Jwt decode(String token) throws JwtException {
        try {
            return delegate.decode(token);
        } catch (JwtException first) {
            // TODO: 여기서 메시지/원인 체인을 보고 "kid not found" 류만 재시도하도록 좁히는 것을 권장
            try {
                return delegate.decode(token);
            } catch (JwtException second) {
                // 원인 보존을 위해 첫 예외를 suppressed로 남김
                second.addSuppressed(first);
                throw second;
            }
        }
    }
}

그리고 Bean 구성에서 감싸면 됩니다.

@Bean
JwtDecoder jwtDecoder() throws Exception {
    JwtDecoder base = /* NimbusJwtDecoder 생성 */;
    return new RetryingJwtDecoder(base);
}

주의할 점:

  • 재시도는 반드시 1회로 제한하세요(폭주 방지)
  • 실패 유형을 좁히지 않으면(예: 만료 토큰) 불필요한 재시도가 늘어납니다
  • IdP 장애 상황에서 재시도가 오히려 지연을 키울 수 있으니 타임아웃을 짧게 잡는 게 중요합니다

해결 4: 관측 가능성(Observability)로 “키 회전 순간”을 잡아내기

401이 진짜 키 회전 때문인지 확인하려면, 다음 로그/메트릭을 남기면 좋습니다.

  • JWT 헤더의 kid (민감정보가 아니지만 정책에 따라 마스킹)
  • 현재 캐시에 존재하는 kid 목록(직접 노출은 부담되면 개수라도)
  • JWK fetch 성공/실패 횟수, 지연 시간
  • 401의 세부 원인 분류(만료/issuer mismatch/kid not found)

Spring Security 디버그 로그는 원인 파악에 도움이 되지만 운영 상시 활성화는 부담입니다. 대신 특정 기간/특정 인스턴스에서만 올리거나, 예외 핸들러에서 원인 체인을 구조화해 남기는 방식을 추천합니다.

운영 팁: 멀티 인스턴스에서 더 자주 터지는 이유

오토스케일 환경에서 “어떤 파드/인스턴스는 되고 어떤 건 안 된다”가 나오면, 대개 다음 중 하나입니다.

  • 인스턴스별로 JWK 캐시가 서로 다른 시점에 갱신됨
  • 특정 인스턴스만 IdP로 나가는 egress가 막힘(DNS/네트워크 정책/프록시)
  • 특정 AZ/노드에서만 지연이 커서 타임아웃으로 실패

이 경우 401을 단순히 애플리케이션 버그로 보기보다, 네트워크 플러그인/노드 상태까지 같이 점검해야 합니다. 쿠버네티스에서 파드 상태가 불안정하면 인증 통신 실패가 401로 보이기도 하니, 필요하면 Kubernetes CrashLoopBackOff 10가지 원인과 15분 진단 같은 체크리스트도 병행하세요.

결론: “리소스 서버 설정”만으로 끝내지 말 것

Spring Boot 3에서 JWT 401이 간헐적으로 발생한다면, 우선 만료/issuer/audience 같은 정적 검증보다 **JWK 캐시와 키 회전(kid 변경)**을 1순위로 의심하는 게 시간을 아낍니다.

정리하면 우선순위는 다음이 실전에서 가장 잘 먹힙니다.

  1. IdP가 키 회전 시 구키/신키 공존 기간을 충분히 제공하는지 확인
  2. 리소스 서버에서 JWK 조회 타임아웃을 명시하고 장애 시 지연을 줄이기
  3. kid not found 류 실패에 한해 1회 재시도(리프레시 유도) 전략 도입
  4. kid 기반으로 원인을 분류하는 로그/메트릭으로 “회전 순간”을 가시화

이 네 가지를 적용하면, “갑자기 401”이라는 운영 이슈가 **예측 가능한 이벤트(키 회전)**로 바뀌고, 장애가 아니라 설계된 동작으로 관리되기 시작합니다.