- Published on
Spring Security JWT 401 - JWK 키회전 캐시 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
운영 중이던 Spring Security Resource Server에서 갑자기 JWT 검증이 실패하며 401 Unauthorized가 늘어나는 경우가 있습니다. 특히 IdP(예: Cognito, Auth0, Keycloak, Azure AD 등)가 JWK를 키 회전(key rotation)한 직후부터 간헐적으로 터지고, 시간이 지나면 자연히 사라지는 패턴이라면 원인은 대개 JWK 캐시와 키 회전 타이밍 불일치입니다.
이 글에서는 Spring Security의 JWT 검증 흐름에서 JWK가 어떻게 캐시되는지, 왜 키 회전 시점에 401이 발생하는지, 그리고 캐시를 안전하게 제어해서 장애를 줄이는 실전 해결책을 정리합니다.
관련해서 인증 플로우 디버깅이 필요하다면 OAuth PKCE invalid_grant·state 불일치 해결 가이드도 함께 참고하면 전체 인증 체인을 이해하는 데 도움이 됩니다.
증상: 키 회전 직후만 401이 튄다
다음과 같은 로그/현상이 반복되면 JWK 캐시 문제 가능성이 높습니다.
- 특정 시점(대개 IdP 키 회전 직후)부터
401급증 - 같은 토큰이 어떤 인스턴스에서는 성공, 어떤 인스턴스에서는 실패
- 몇 분에서 몇 시간 후 자연 회복
- 로그에
JwtValidationException,Invalid signature,Signed JWT rejected류 메시지 - 토큰 헤더의
kid값이 새로운 키인데, 서버는 이전 JWK 세트를 보고 있음
클라이언트가 들고 온 JWT는 헤더에 kid(Key ID)를 포함합니다. 리소스 서버는 kid에 해당하는 공개키를 JWK Set에서 찾아 서명을 검증합니다. 문제는 이 JWK Set을 매 요청마다 가져오지 않고, 성능을 위해 캐시한다는 점입니다.
원인: JWK Set 캐시가 새 키를 못 본다
Spring Security Resource Server의 기본 동작은 대략 다음과 같습니다.
issuer-uri또는jwk-set-uri로부터 메타데이터 및 JWK Set을 조회- JWK Set을 내부 캐시에 저장
- 각 요청의 JWT에서
kid를 읽고, 캐시된 JWK Set에서 매칭되는 키로 검증
키 회전이 발생하면 IdP는 새로운 키를 JWK Set에 추가하거나 기존 키를 교체합니다. 여기서 401이 발생하는 전형적 시나리오는 두 가지입니다.
1) IdP는 새 kid로 토큰을 발급했는데, 리소스 서버 캐시는 구버전
- 클라이언트: 새 키로 서명된 토큰 수신(헤더
kid가 새 값) - 리소스 서버: 캐시된 JWK Set에는 해당
kid가 없음 - 결과: 서명 검증 실패로
401
2) 분산 환경에서 인스턴스별 캐시 갱신 타이밍이 다름
- 인스턴스 A: 캐시 갱신되어 새 키 인지
- 인스턴스 B: 캐시가 아직 구버전
- 로드밸런서 라우팅에 따라 성공/실패가 섞여 “간헐적 401”로 보임
이 문제는 K8s/ECS 같은 환경에서 특히 흔합니다. 장애 원인을 빨리 좁히는 운영 관점은 K8s CrashLoopBackOff 원인 10분내 찾는 법처럼 “증상-로그-재현-원인” 순으로 접근하는 게 좋습니다.
빠른 확인: 토큰의 kid와 JWK Set 비교
가장 먼저 할 일은 “실패한 토큰의 kid가 JWK Set에 존재하는지” 확인하는 것입니다.
1) 실패한 JWT에서 kid 확인
TOKEN="eyJhbGciOiJSUzI1NiIsImtpZCI6IjEyMy4uLiJ9.eyJpc3MiOiJodHRwczovL2lzc3VlciIsLi4ufQ.signature"
# 헤더 디코딩(kid 확인)
echo "$TOKEN" | cut -d '.' -f 1 | base64 -d 2>/dev/null | jq
환경에 따라 base64 패딩 문제로 실패할 수 있으니, 필요하면 python으로 디코딩해도 됩니다.
2) JWK Set에서 kid 존재 여부 확인
JWK_SET_URL="https://issuer.example.com/.well-known/jwks.json"
curl -s "$JWK_SET_URL" | jq '.keys[].kid'
- 토큰
kid가 JWK Set에 없다면: 캐시 갱신 지연 또는 IdP 배포/전파 지연 가능성이 큽니다. - JWK Set에는 있는데도 실패한다면: 알고리즘 불일치,
issuer/audience설정 오류, 프록시/캐시가 JWK 응답을 잘못 캐싱하는 문제 등도 의심해야 합니다.
해결 전략 1: JWK 캐시 TTL과 리프레시 정책을 명시적으로 제어
Spring Security에서 JWK를 가져오는 컴포넌트는 내부적으로 Nimbus 라이브러리를 사용합니다. 운영에서 중요한 포인트는 다음입니다.
- JWK 캐시 TTL을 너무 길게 두면 키 회전 직후 장애가 길어진다.
- 너무 짧게 두면 모든 인스턴스가 JWK Set을 자주 조회해 IdP에 부하를 준다.
- 최적은 “짧은 TTL + 실패 시 즉시 리프레시” 패턴이다.
아래 예시는 NimbusJwtDecoder를 직접 구성해 JWK Set 캐시 동작을 제어하는 방식입니다. (Spring Boot 자동설정보다 한 단계 내려가 커스터마이징)
Spring Security 설정 예시: 캐시/리프레시 제어
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 com.nimbusds.jose.util.ResourceRetriever;
import org.springframework.beans.factory.annotation.Value;
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
public JwtDecoder jwtDecoder(
@Value("${security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwkSetUri
) throws Exception {
// 타임아웃을 명시적으로 설정(네트워크 이슈 시 장애 전파 방지)
int connectTimeoutMs = 1000;
int readTimeoutMs = 2000;
int sizeLimitBytes = 1024 * 1024;
ResourceRetriever retriever = new DefaultResourceRetriever(
connectTimeoutMs,
readTimeoutMs,
sizeLimitBytes
);
// RemoteJWKSet은 내부적으로 JWK Set을 캐시한다.
// (TTL/리프레시 정책은 라이브러리 버전에 따라 옵션이 다르므로,
// 여기서는 네트워크/타임아웃과 함께 "실패 시 재조회"가 일어나도록 구성을 분리한다.)
JWKSource<SecurityContext> jwkSource = new RemoteJWKSet<>(new URL(jwkSetUri), retriever);
return NimbusJwtDecoder.withJwkSource(jwkSource).build();
}
}
주의할 점은 “TTL을 어디서 조정하느냐”가 Spring 버전 및 Nimbus 버전에 따라 달라질 수 있다는 것입니다. 그래서 운영에서는 다음 두 가지를 함께 권장합니다.
- (필수) JWK 조회 타임아웃을 짧게 잡아 장애 전파를 줄인다.
- (권장) 키 회전 정책에 맞춰 JWK 캐시 TTL을 조정하거나, 최소한 “서명 검증 실패 시 강제 리프레시”가 가능한 구조로 만든다.
만약 프레임워크/라이브러리 업그레이드가 가능하다면, JWK 캐시 TTL을 명확히 설정할 수 있는 버전으로 올리는 것도 실전적인 해법입니다.
해결 전략 2: kid 미스매치 시 JWK 강제 리프레시(재조회) 패턴
가장 효과적인 운영 패턴은 다음입니다.
- 평상시에는 캐시 사용
- JWT 검증 중
kid를 못 찾거나 서명 검증이 특정 예외로 실패하면 - JWK Set을 즉시 한 번 재조회 후 재검증
이렇게 하면 “키 회전 직후 첫 요청”에서만 비용이 발생하고, 이후는 정상화됩니다.
아래는 개념 예시입니다. 실제 구현은 사용하는 Nimbus API 버전에 맞게 조정해야 하지만, 핵심은 검증 실패를 트리거로 JWK를 갱신하는 것입니다.
import org.springframework.security.oauth2.jwt.*;
public class RefreshingJwtDecoder implements JwtDecoder {
private final JwtDecoder primary;
private final Runnable refreshAction;
public RefreshingJwtDecoder(JwtDecoder primary, Runnable refreshAction) {
this.primary = primary;
this.refreshAction = refreshAction;
}
@Override
public Jwt decode(String token) throws JwtException {
try {
return primary.decode(token);
} catch (JwtException ex) {
// 여기서 예외 타입/메시지로 "kid not found" / "invalid signature" 등을 선별해도 된다.
refreshAction.run();
return primary.decode(token);
}
}
}
refreshAction은 내부적으로 JWK 캐시를 비우거나, 새로운 JwtDecoder 인스턴스를 재생성하는 식으로 구현할 수 있습니다. 다만 재생성은 동시성 이슈가 생길 수 있으니 AtomicReference로 디코더를 교체하거나, 갱신 락을 두는 게 안전합니다.
해결 전략 3: 인프라/프록시 캐시로 JWK 응답이 잘못 캐싱되는지 점검
의외로 많은 케이스가 “애플리케이션 캐시”가 아니라 중간 캐시 문제입니다.
- 사내 프록시가
jwks.json응답을 과도하게 캐싱 - CDN이
Cache-Control을 무시하거나 TTL을 강제 - WAF/게이트웨이가 특정 응답을 오래 저장
점검 방법:
- 리소스 서버에서 직접 IdP로 나가는지(egress 경로) 확인
jwks.json응답 헤더의Cache-Control,Age,ETag확인- 동일 URL을 여러 노드에서 조회했을 때 응답이 동일한지 비교
예시:
curl -I "https://issuer.example.com/.well-known/jwks.json"
여기서 Age가 과도하게 크거나, Cache-Control이 기대와 다르면 중간 캐시를 의심해야 합니다.
해결 전략 4: 키 회전 운영 정책 자체를 조정(겹침 기간 확보)
IdP 키 회전은 보통 아래 정책을 권장합니다.
- 새 키를 JWK Set에 먼저 추가
- 일정 시간 동안 구 키와 신 키를 함께 제공 (overlap)
- 토큰 발급은 점진적으로 새 키로 전환
- 구 키 제거는 토큰 최대 수명(
exp)이 지난 뒤
겹침 기간이 없으면, 캐시가 조금만 늦어도 바로 401이 발생합니다. 즉, 애플리케이션에서 캐시를 잘 다뤄도 키 회전 정책이 공격적이면 장애가 날 수 있습니다.
Spring Boot 설정 체크리스트: issuer/audience도 함께 확인
JWK 캐시가 원인인 경우가 많지만, 키 회전과 동시에 다음 설정이 얽혀 401로 보이는 경우도 있습니다.
issuer-uri변경(테넌트/도메인 변경)aud불일치(클라이언트/리소스 식별자 변경)- 알고리즘 변경(
RS256에서ES256등)
기본 설정 예시:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: "https://issuer.example.com/"
# 또는 jwk-set-uri를 직접 지정
# jwk-set-uri: "https://issuer.example.com/.well-known/jwks.json"
운영에서는 issuer-uri를 쓰는 편이 메타데이터(.well-known/openid-configuration)까지 따라가므로 안전한 경우가 많지만, 네트워크 경로가 복잡하거나 메타데이터 조회가 불안정하면 jwk-set-uri를 고정하는 전략도 고려할 수 있습니다.
관측/알림: 401을 "사용자 오류"로 취급하지 말 것
키 회전 캐시 문제는 사용자 요청이 아니라 서버 구성/외부 의존성 문제인 경우가 많습니다. 따라서 관측 포인트를 분리하는 게 중요합니다.
- 401 비율이 특정 시간대에 급증하면 경보
- JWT 검증 예외를 별도 메트릭으로 카운트
- 예외 메시지에서
kid관련 패턴을 추출해 집계
또한 다중 인스턴스 환경에서는 “어느 노드에서 실패했는지”가 핵심 단서이므로, 로그에 pod name 또는 instance id를 반드시 포함시키세요.
결론: 최적 해법은 "짧은 TTL + 실패 시 즉시 리프레시"
Spring Security JWT 401이 JWK 키 회전 직후에만 간헐적으로 발생한다면, 문제의 본질은 거의 항상 JWK Set 캐시와 키 회전 이벤트의 시간차입니다.
실전 대응 우선순위는 다음이 좋습니다.
- 실패 토큰의
kid와 JWK Set의kid를 비교해 원인을 확정 - JWK 조회 타임아웃/네트워크 경로 안정화
- 캐시 TTL을 과도하게 길게 두지 않기
- 검증 실패 시 JWK 강제 리프레시(재조회) 패턴 도입
- IdP 키 회전 정책에 겹침 기간 확보
이 조합이면 키 회전이 있어도 401 스파이크를 크게 줄이고, “자연 회복을 기다리는” 운영에서 벗어날 수 있습니다.