- Published on
Spring Security JWT 401 - 키 로테이션과 JWKS 캐시
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
운영 중인 Spring Security 리소스 서버에서 갑자기 401 Unauthorized가 늘어나는 순간이 있습니다. 특히 인증 서버가 RSA 키 로테이션을 수행한 직후, 특정 노드에서만 간헐적으로 실패하거나 짧은 시간 동안 폭증했다가 자연히 사라지는 패턴이라면 JWKS 캐시와 키 로테이션 타이밍 불일치를 가장 먼저 의심해야 합니다.
이 글에서는 다음을 다룹니다.
- 키 로테이션이 왜
401을 만들 수 있는지(서명 검증 관점) - Spring Security
NimbusJwtDecoder가 JWKS를 어떻게 캐시하는지 - 캐시 무효화/리프레시 전략(짧은 TTL, 백오프, 강제 리프레시)
- 운영에서 자주 놓치는 체크포인트(로드밸런서, 멀티 인스턴스, 헤더)
- 실전 코드 예제(커스텀
JwtDecoder, 캐시 제어)
관련해서 서명 검증 실패의 더 넓은 원인 스펙트럼은 아래 글도 함께 참고하면 진단 속도가 빨라집니다.
401이 “키 로테이션 직후”에만 터지는 이유
JWT 서명 검증은 요약하면 다음 흐름입니다.
- 토큰의 헤더에서
kid(Key ID),alg등을 읽음 kid에 해당하는 공개키를 JWKS에서 찾아옴- 서명 검증 수행
키 로테이션 시나리오에서 문제가 생기는 전형적인 케이스는 이렇습니다.
- 인증 서버가 새 키를 생성하고, 새
kid로 토큰을 발급하기 시작함 - 리소스 서버는 이전에 받아둔 JWKS를 캐시하고 있음
- 캐시된 JWKS에는 새
kid가 없음 - 결과적으로 “해당
kid를 찾을 수 없음” 혹은 “서명 검증 실패”로401발생
핵심은 리소스 서버가 JWKS를 ‘항상’ 실시간으로 가져오지 않는다는 점입니다. 대부분의 구현체는 네트워크 비용과 장애 전파를 줄이기 위해 JWKS를 캐시합니다.
Spring Security의 JWKS 로딩과 캐시: 어디서 결정되나
Spring Security 리소스 서버에서 spring.security.oauth2.resourceserver.jwt.jwk-set-uri를 설정하면 내부적으로 NimbusJwtDecoder가 사용됩니다. Nimbus는 JWKS를 원격에서 가져오고, 일정 기간 캐시합니다.
문제는 다음 두 가지가 겹칠 때 심해집니다.
- 캐시 TTL이 길거나(혹은 기본 동작을 정확히 모르고 방치)
- 키 로테이션이 “기존 키 제거”까지 포함하는 공격적인 방식일 때
권장되는 로테이션은 보통 “새 키 추가 → 일정 기간 병행 → 구 키 제거”인데, 구 키를 너무 빨리 제거하면 반대 방향의 장애도 납니다.
- 아직 구 키로 서명된 토큰이 유효한데
- JWKS에서 구 키가 사라져
- 리소스 서버가 검증을 못해
401
즉, 로테이션은 “새 키를 배포하는 문제”이면서 동시에 “구 키를 언제 제거할지”의 문제입니다.
증상으로 구분하는 진단 포인트
1) 로그에 kid 미스매치가 보인다
리소스 서버에서 디버그 로그를 높이면(또는 예외 메시지) 다음 류의 힌트가 나옵니다.
kid에 해당하는 키를 찾지 못함Invalid signature또는Another algorithm expected등
이때 토큰 헤더의 kid와 JWKS 응답의 keys[].kid를 비교하면 원인이 빠르게 드러납니다.
2) 일부 인스턴스에서만 401이 난다
멀티 인스턴스 환경에서 특정 Pod만 실패한다면, 각 인스턴스의 JWKS 캐시 갱신 시점이 달라서 생기는 전형적인 현상입니다.
3) 로테이션 직후 짧은 스파이크
키 로테이션 직후 트래픽이 많은 시스템에서 401이 순간적으로 늘었다가 줄어드는 경우는, 캐시가 만료되면서 자연히 새 JWKS를 받아 해결되는 패턴입니다.
운영 설계: 키 로테이션을 “안전하게” 만드는 규칙
키 로테이션 자체보다, 키 수명과 토큰 수명, 캐시 수명의 관계가 중요합니다.
규칙 1) 구 키는 토큰 만료까지 유지
- 액세스 토큰 TTL이 15분이라면, 구 키는 최소 15분 이상 JWKS에 남겨야 합니다.
- 리프레시 토큰까지 고려할 필요는 보통 없습니다(리프레시 토큰은 서명 검증 대상이 아닐 수 있음). 다만 시스템 설계에 따라 다릅니다.
규칙 2) JWKS 캐시 TTL은 “로테이션 주기”보다 훨씬 짧게
예:
- 로테이션: 24시간마다
- JWKS 캐시 TTL: 5분~15분
캐시 TTL이 6시간인데 로테이션이 1시간마다면, 장애는 거의 필연입니다.
규칙 3) Cache-Control 헤더를 JWKS 엔드포인트에서 올바르게 제공
인증 서버가 JWKS 응답에 다음을 명확히 주면, 리소스 서버/중간 프록시가 더 예측 가능하게 동작합니다.
Cache-Control: public, max-age=300같은 형태
단, 프록시나 CDN이 JWKS를 과도하게 캐시하는 경우도 있으니(특히 max-age가 길 때) 주의가 필요합니다.
해결 전략 1: 키 로테이션 정책부터 바꾸기(가장 확실)
가장 좋은 해결은 코드가 아니라 정책입니다.
- 새 키를 JWKS에 “추가”
- 일정 기간(액세스 토큰 TTL + 여유분) 동안 구 키와 새 키를 “동시 제공”
- 그 후 구 키 제거
이 방식이면 리소스 서버가 JWKS를 늦게 갱신해도 kid를 찾을 확률이 높아지고, 장애 창이 극적으로 줄어듭니다.
해결 전략 2: Spring Security에서 JWKS 캐시/리프레시를 제어
환경에 따라 “정책만으로는 부족”할 때가 있습니다.
- 로테이션이 아주 잦다
- 인증 서버가 멀티 리전이고 전파 지연이 있다
- 특정 프록시가 JWKS를 예상보다 오래 캐시한다
이럴 때는 리소스 서버 쪽에서 kid 미스 시 JWKS를 강제 리프레시하는 전략이 효과적입니다.
아래 예시는 개념을 보여주기 위한 코드이며, 실제로는 스레드 안전성/백오프/서킷브레이커를 함께 고려해야 합니다.
예시: kid 미스매치 시 디코더 재생성(강제 갱신)
import org.springframework.security.oauth2.jwt.*;
import java.util.concurrent.atomic.AtomicReference;
public class RefreshingJwtDecoder implements JwtDecoder {
private final String jwkSetUri;
private final AtomicReference<JwtDecoder> delegate = new AtomicReference<>();
public RefreshingJwtDecoder(String jwkSetUri) {
this.jwkSetUri = jwkSetUri;
this.delegate.set(buildDecoder());
}
@Override
public Jwt decode(String token) throws JwtException {
try {
return delegate.get().decode(token);
} catch (JwtException e) {
// 1) kid 미스, 서명 검증 실패 등에서만 제한적으로 리프레시 권장
// 2) 무한 재시도 방지를 위해 1회만 리프레시하거나 백오프 필요
delegate.set(buildDecoder());
return delegate.get().decode(token);
}
}
private JwtDecoder buildDecoder() {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
// 필요 시 issuer/audience 검증도 함께 설정
// decoder.setJwtValidator(...);
return decoder;
}
}
위 방식은 단순하지만, 모든 JwtException에서 갱신하면 오히려 인증 서버 장애 시 리소스 서버가 JWKS를 과도하게 두드릴 수 있습니다. 따라서 다음을 권장합니다.
- 예외 메시지/원인으로
kid관련 오류만 선별 - 리프레시 빈도 제한(예: 최소 30초에 1회)
- 인증 서버 장애 시 빠르게 포기(서킷브레이커)
Spring Bean 등록 예시
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.jwt.JwtDecoder;
@Configuration
public class SecurityJwtConfig {
@Bean
JwtDecoder jwtDecoder() {
String jwkSetUri = "https://issuer.example.com/.well-known/jwks.json";
return new RefreshingJwtDecoder(jwkSetUri);
}
}
해결 전략 3: 관측 가능성(Observability)로 “원인”을 빠르게 고정
키 로테이션/JWKS 캐시 문제는 재현이 까다로워서, 메트릭과 로그 설계가 해결의 절반입니다.
권장 로그/메트릭
- JWT 디코딩 실패 카운트(라벨:
reason,issuer,kid) - JWKS fetch 성공/실패 카운트
- JWKS fetch 소요 시간
- 현재 캐시된 JWKS의
kid목록(디버그 엔드포인트로 제한적으로)
kid는 민감정보는 아니지만, 운영 정책에 따라 노출을 제한하세요.
흔한 함정: JWKS는 맞는데도 401이 나는 케이스
키 로테이션과 무관해 보이지만, 현장에서 같이 섞여서 나타나는 케이스가 많습니다.
1) alg 혼용 또는 기대 알고리즘 불일치
예: 토큰은 RS256인데 리소스 서버는 HS256을 기대하거나 그 반대.
2) iss(issuer) 또는 aud(audience) 검증 실패
서명은 맞지만 “누가 발급했는지/누구를 위한 토큰인지” 검증에서 실패하면 401이 납니다.
3) 로드밸런서/프록시가 JWKS를 오래 캐시
인증 서버는 max-age=60을 줬는데, 중간 캐시 계층이 더 길게 잡는 경우가 있습니다. 특히 사내 프록시, CDN 정책이 얽히면 애매해집니다.
인프라 계층 문제는 401과 함께 다른 증상(간헐적 타임아웃, 특정 AZ만 문제)이 동반되기도 합니다. 비슷한 결의 “겉보기엔 앱 문제인데 인프라가 원인”인 사례로는 다음 글의 접근법이 참고가 됩니다.
실전 체크리스트: 키 로테이션·JWKS 캐시로 인한 401 줄이기
인증 서버(issuer) 측
- JWKS 엔드포인트에
Cache-Control을 명시했는가 - 새 키 추가 후 구 키 제거까지의 유예 기간이 액세스 토큰 TTL보다 충분히 긴가
- JWKS에 동일
kid를 재사용하지 않는가(재사용은 디버깅을 지옥으로 만듦)
리소스 서버(Spring Security) 측
issuer-uri또는jwk-set-uri설정이 올바른가kid미스 시 JWKS 강제 갱신 전략이 필요한 트래픽/로테이션 빈도인가- JWKS fetch 실패 시 과도한 재시도를 막는가(백오프, 서킷브레이커)
네트워크/캐시 계층
- CDN/프록시가 JWKS를 정책적으로 캐시하고 있지 않은가
- 인증 서버와 리소스 서버 간 DNS/라우팅이 리전별로 달라 전파 지연이 생기지 않는가
마무리
Spring Security에서 JWT 401이 키 로테이션 타이밍에 맞춰 간헐적으로 발생한다면, 문제의 본질은 대개 **서명키 자체가 아니라 “JWKS 캐시의 시간차”**입니다.
- 가장 먼저 로테이션 정책을 “병행 기간 포함”으로 안전하게 만들고
- 그래도 부족하면 리소스 서버에서
kid미스 시 제한적으로 JWKS를 리프레시하며 - 메트릭/로그로
kid와 JWKS 갱신 이벤트를 관측 가능하게 만들면
재현이 어려운 401 스파이크도 운영 관점에서 안정적으로 제어할 수 있습니다.