- Published on
Spring Security JWT 401 - JWK 로테이션·캐시 대응
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 잘 돌다가 특정 시점부터 JWT 인증이 간헐적으로 401 Unauthorized로 떨어지면, 대부분은 토큰 자체가 아니라 JWK(JSON Web Key) 로테이션과 캐시 전략의 충돌에서 시작합니다. 특히 IdP(Auth0, Cognito, Keycloak 등)가 키를 회전시키는 순간, 리소스 서버가 이전 키만 들고 있으면 서명 검증이 실패하고 401이 발생합니다.
이 글에서는 Spring Security(OAuth2 Resource Server)가 JWT를 검증할 때 어떤 경로로 JWK를 가져오고 캐시하는지, 로테이션 시나리오에서 왜 401이 터지는지, 그리고 운영에서 안전하게 풀어내는 캐시·리프레시 전략을 코드 중심으로 정리합니다.
증상: “어제까지 되던 토큰이 오늘은 401”
대표적인 로그 패턴은 다음과 같습니다.
JwtValidationException혹은BadJwtException- 원인 메시지에
Signed JWT rejected: Another algorithm expected또는Cannot verify signature계열 kid(Key ID)가 토큰 헤더에 있는데, 리소스 서버가 가진 JWK Set에 해당kid가 없음
즉, 토큰은 정상인데 검증자가 최신 공개키를 못 받아온 상태입니다.
왜 JWK 로테이션이 401을 만든다
JWT 서명 검증 흐름을 단순화하면 아래와 같습니다.
- 클라이언트가 JWT를 들고 API 호출
- Spring Security가 JWT 헤더의
kid를 확인 jwks_uri에서 JWK Set을 가져오거나(또는 캐시에서 꺼내거나)- JWK Set에서
kid에 맞는 공개키를 찾아 서명 검증
여기서 IdP가 키를 로테이션하면 다음 상황이 생깁니다.
- 새 토큰은 새
kid로 서명됨 - 리소스 서버 캐시에는 이전
kid만 있음 - 결과: 해당
kid의 공개키를 못 찾아 검증 실패,401
문제는 “로테이션 자체”가 아니라 “로테이션 직후 일정 시간 동안 캐시가 갱신되지 않는 구간”입니다.
Spring Security의 기본 동작 이해하기
Spring Security Resource Server는 보통 issuer-uri 또는 jwk-set-uri를 기반으로 NimbusJwtDecoder를 구성합니다.
issuer-uri를 쓰면 OIDC discovery를 통해jwks_uri를 찾습니다.jwk-set-uri를 직접 지정하면 그 URL에서 JWK Set을 가져옵니다.
중요한 포인트는, JWK를 가져오는 HTTP 호출과 캐시가 내부적으로 일어나며, 기본값은 운영 요구사항(로테이션 빈도, 장애, 트래픽)에 딱 맞지 않을 수 있다는 점입니다.
401을 줄이는 핵심 전략 4가지
1) JWK 캐시 TTL을 “너무 길게” 잡지 않는다
JWK는 공개키이므로 상대적으로 공격 표면은 낮지만, 캐시를 과도하게 길게 잡으면 로테이션 대응이 늦어집니다.
권장 접근:
- IdP의 로테이션 정책을 확인
- TTL을 “로테이션보다 충분히 짧게”
- 단, 너무 짧으면 매 요청마다 JWK fetch가 일어나 IdP에 부하를 줄 수 있으니, 아래의 “실패 시 리프레시”와 함께 설계
2) kid 미스매치 시 즉시 JWK 리프레시
가장 실전적인 해법은 이겁니다.
- 평소에는 캐시 사용
- 검증 실패 원인이 “해당
kid없음”이라면 JWK Set을 강제 갱신 - 갱신 후 재검증
이 패턴을 구현하려면 NimbusJwtDecoder의 JWK 소스/캐시를 커스터마이징하거나, 실패 이벤트를 감지해 디코더를 재생성하는 전략을 씁니다.
아래는 “디코더를 주기적으로 재생성”하는 단순하지만 효과적인 예시입니다(운영에서는 캐시와 락을 더 정교하게 가져가세요).
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.util.concurrent.atomic.AtomicReference;
@Configuration
public class JwtDecoderConfig {
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
String jwkSetUri;
// 매우 단순화한 예시: 필요 시 교체 가능한 디코더 래퍼
@Bean
public JwtDecoder jwtDecoder() {
AtomicReference<JwtDecoder> ref = new AtomicReference<>(build());
return token -> {
try {
return ref.get().decode(token);
} catch (Exception first) {
// 운영에서는 여기서 예외 원인이 "kid not found" 계열인지 판별 후에만 갱신 권장
ref.set(build());
return ref.get().decode(token);
}
};
}
private JwtDecoder build() {
return NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
}
}
이 방식은 “로테이션 직후”에 발생하는 401을 크게 줄여줍니다. 다만 모든 예외에서 재시도하면 불필요한 JWK fetch가 늘 수 있으니, 반드시 예외 메시지/타입을 기준으로 조건부 리프레시를 넣는 편이 좋습니다.
3) JWK fetch 장애를 401로 오인하지 않기
운영에서 자주 겪는 함정은 이겁니다.
- IdP의
jwks_uri가 일시적으로 느리거나 5xx - 리소스 서버가 JWK 갱신에 실패
- 결과적으로 JWT 검증 실패처럼 보여
401이 나감
하지만 이건 인증 실패가 아니라 “키 조회 인프라 장애”입니다. 이때는 401보다 503이 더 의미적으로 맞을 수 있고, 최소한 모니터링에서는 분리되어야 합니다.
실제로 Kubernetes/EKS 환경에서는 네트워크 경로 문제나 Ingress 튜닝 문제로 외부 호출이 불안정해질 수 있습니다. 비슷한 결의 장애 진단 관점은 다음 글도 참고할 만합니다.
권장 사항:
- JWK fetch 실패를 별도 메트릭으로 집계
jwks_uri호출 타임아웃을 명시- 리소스 서버 내부 캐시가 있다면 “마지막으로 성공한 JWK”를 일정 시간 유지(soft-fail)하고, 그 기간 동안만 기존 키로 검증 허용
4) 멀티 인스턴스 환경에서 “캐시 동기화”를 고민한다
인스턴스가 여러 대면, 어떤 파드에서는 캐시가 갱신됐고 어떤 파드에서는 안 됐을 수 있습니다. 그러면 같은 토큰이 어떤 요청에서는 200, 어떤 요청에서는 401이 됩니다.
해결 방향은 3가지입니다.
- 각 인스턴스가
kid미스매치 시 즉시 리프레시(가장 흔함) - 중앙 캐시(예: Redis)에 JWK Set을 저장하고 공유
- 배포/운영 레벨에서 로테이션 직후 캐시 워밍(warm-up) 요청을 보내 모든 파드가 JWK를 미리 당겨오게 함
중앙 캐시를 쓰면 일관성은 좋아지지만, Redis 장애가 인증 경로를 망칠 수 있으니 “로컬 캐시 + 중앙 캐시”의 계층형 구조를 고려하는 경우도 많습니다.
권장 구성: issuer-uri vs jwk-set-uri
issuer-uri장점: OIDC discovery 기반이라 설정이 단순하고, 키 URL 변경에도 상대적으로 유연jwk-set-uri장점: 네트워크 경로를 명시적으로 통제 가능, 프록시/캐시 계층을 붙이기 쉬움
운영에서 jwks_uri를 별도의 내부 프록시로 감싸고(예: 사내 API Gateway), 그 프록시에서 캐시 헤더와 장애 대응을 구현하는 패턴도 자주 씁니다.
디버깅 체크리스트
1) 토큰 헤더의 kid 확인
JWT를 디코드해서 헤더를 봅니다(서명 검증 전 단계).
python - << 'PY'
import base64, json
def b64url_decode(s):
s += '=' * (-len(s) % 4)
return base64.urlsafe_b64decode(s.encode())
token = input().strip()
header = token.split('.')[0]
print(json.loads(b64url_decode(header)))
PY
출력에서 kid를 확인한 뒤, 실제 JWK Set에 그 kid가 존재하는지 비교합니다.
2) JWK Set에서 현재 키 목록 확인
curl -sS "https://YOUR_IDP_DOMAIN/.well-known/jwks.json" | jq '.keys[] | {kid, kty, alg, use}'
여기서 토큰의 kid가 없다면, 리소스 서버가 캐시를 갱신하지 못했거나 IdP 로테이션 직후 갱신 타이밍이 어긋난 겁니다.
3) 캐시 갱신 실패인지, 진짜 인증 실패인지 분리
kid가 존재하는데도 실패한다면:alg불일치(예: RS256 기대했는데 다른 알고리즘)- issuer/audience 설정 불일치
- 토큰 만료
kid가 JWK에 없어서 실패한다면:- 로테이션 + 캐시 이슈 가능성이 매우 큼
운영 팁: 로테이션 윈도우를 고려한 정책
IdP는 보통 “새 키 추가 + 일정 기간 구 키 유지 + 구 키 제거” 순서로 로테이션합니다. 이때 리소스 서버는 다음을 만족해야 안전합니다.
- JWK 캐시 TTL이 너무 길지 않을 것
kid불일치 시 즉시 리프레시할 것- JWK fetch 장애 시 관측 가능(메트릭/로그)할 것
- 다중 인스턴스에서 일관성 전략이 있을 것
추가로, OAuth/OIDC 계열 문제는 브라우저 정책 변화나 리다이렉트 구성 문제와 함께 복합적으로 나타나기도 합니다. 인증 흐름 자체가 불안정하다면 아래 글도 함께 보면 원인 분리에 도움이 됩니다.
마무리: 401을 “토큰 문제”로 단정하지 말기
Spring Security JWT의 401은 토큰이 잘못된 경우도 많지만, 운영에서 더 골치 아픈 케이스는 “JWK 로테이션과 캐시 불일치”처럼 시스템 경계에서 발생하는 문제입니다.
가장 효과적인 처방은 다음 한 줄로 요약됩니다.
- 평소에는 캐시를 쓰되,
kid기반 검증 실패 시 JWK를 즉시 갱신하고 재검증하라
여기에 JWK fetch 장애 관측, 멀티 인스턴스 일관성, TTL 튜닝까지 더하면 로테이션 타이밍의 간헐적 401을 실질적으로 제거할 수 있습니다.