- Published on
Spring Boot 3 JWT 401 - JWK 캐시·키회전 완전 정복
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 멀쩡한데 특정 시점부터 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 검증은 보통 다음 흐름입니다.
- 클라이언트가 Bearer 토큰(JWT)을 보냄
- 리소스 서버가 JWT 헤더의
kid(Key ID)를 확인 jwk-set-uri에서 JWK 세트를 가져와kid에 해당하는 공개키로 서명 검증- 성공하면 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 JwtJwtValidationException: ...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 만들기
운영에서 일어나던 일을 로컬에서 재현하면 원인 파악이 빨라집니다.
- IdP가 JWK Set에 키 A(
kid=a)만 제공 - 리소스 서버가 JWK를 가져와 캐시
- IdP가 키를 회전해 키 B(
kid=b)로 서명하기 시작 - JWK Set에서 키 A를 즉시 제거(또는 너무 빨리 제거)
- 리소스 서버는 여전히 캐시에 키 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에서 이를 우아하게 넣는 방법은 여러 가지가 있지만, 실무적으로는 다음 중 하나를 선택합니다.
- API Gateway/WAF 레벨에서 401 재시도(권장도는 환경에 따라 다름)
- 리소스 서버에서 커스텀
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순위로 의심하는 게 시간을 아낍니다.
정리하면 우선순위는 다음이 실전에서 가장 잘 먹힙니다.
- IdP가 키 회전 시 구키/신키 공존 기간을 충분히 제공하는지 확인
- 리소스 서버에서 JWK 조회 타임아웃을 명시하고 장애 시 지연을 줄이기
kid not found류 실패에 한해 1회 재시도(리프레시 유도) 전략 도입kid기반으로 원인을 분류하는 로그/메트릭으로 “회전 순간”을 가시화
이 네 가지를 적용하면, “갑자기 401”이라는 운영 이슈가 **예측 가능한 이벤트(키 회전)**로 바뀌고, 장애가 아니라 설계된 동작으로 관리되기 시작합니다.