- Published on
Spring Security JWT 401 원인 - 시계오차·키롤오버
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
운영 중인 Spring Security JWT 인증이 “가끔” 401을 반환할 때, 원인은 의외로 애플리케이션 로직이 아니라 시간과 키 관리에 있는 경우가 많습니다. 특히 다음 두 가지는 장애가 간헐적이고 재현이 어려워서, 한 번 겪으면 꽤 오래 고생합니다.
- 시계 오차(clock skew): 토큰의
exp,nbf,iat검증 시 서버 간 시간이 미세하게 어긋나서 만료/미래 토큰으로 판정 - 키 롤오버(key rollover): 서명 키를 교체했는데 일부 인스턴스/리소스 서버가 이전 키만 알고 있어 서명 검증 실패
이 글에서는 Spring Security(특히 Resource Server JWT) 기준으로 401의 원인을 빠르게 좁히고, 안전하게 완화/해결하는 방법을 코드와 설정으로 정리합니다.
401이 “토큰 없음”인지 “토큰 불일치”인지 먼저 분리
JWT 401은 크게 두 부류로 나뉩니다.
- 인증 헤더 자체가 없거나 형식이 틀림
Authorization헤더 누락Bearer접두어 누락- 프록시/게이트웨이에서 헤더 제거
- 토큰은 왔지만 검증 실패
- 만료(
exp) - 아직 유효하지 않음(
nbf) - 발급 시간이 미래(
iat) - 서명 불일치(키 롤오버, 잘못된 키)
iss,aud불일치
운영에서 “간헐적”이라면 2)일 확률이 높고, 그중에서도 clock skew와 키 롤오버가 상위권입니다.
진단을 위한 로그 포인트
Spring Security Resource Server를 사용한다면, 아래 로거를 올려서 원인 메시지를 확보하세요.
# application.properties
logging.level.org.springframework.security=DEBUG
logging.level.org.springframework.security.oauth2=DEBUG
로그에서 다음 단서가 자주 보입니다.
JwtValidationException: An error occurred while attempting to decode the JwtJwtException: Jwt expired at ...Invalid signatureJwt timestamp is in the future
이 메시지가 확보되면 “시계”인지 “키”인지 절반은 끝납니다.
원인 1: 시계 오차(clock skew)로 인한 exp/nbf/iat 실패
왜 시계 오차가 401을 만들까
JWT는 보통 다음 클레임으로 시간 검증을 합니다.
exp: 만료 시각nbf: 이 시각 이전에는 사용 불가iat: 발급 시각
발급 서버(Authorization Server)와 검증 서버(Resource Server) 시간이 몇 초만 어긋나도, 아래 같은 상황이 생깁니다.
- 발급 서버 시간이 조금 빠름: Resource Server가
iat가 미래라고 판단 - Resource Server 시간이 조금 빠름: 아직
nbf전이라고 판단하거나exp가 지났다고 판단
특히 쿠버네티스/EKS 같은 환경에서 노드 NTP 동기화가 흔들리거나, VM/베어메탈 혼재, 컨테이너 재기동 직후 시간 보정이 튀는 경우에 간헐적으로 터집니다.
재현: 짧은 만료 + 시간 오차
만료가 60초 이하로 짧거나, nbf를 엄격히 쓰는 시스템은 1~5초 오차에도 민감합니다. “가끔 로그인 직후 401” 같은 증상이 대표적입니다.
해결 1) JWT 디코더에 clock skew 허용(권장)
Spring Security Resource Server에서는 JwtDecoder에 validator를 붙여 허용 오차를 둘 수 있습니다.
아래는 issuer-uri 기반(JWK Set 사용)에서 60초 허용을 적용하는 예시입니다.
import java.time.Duration;
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.jwt.*;
@Configuration
public class JwtConfig {
@Bean
public JwtDecoder jwtDecoder(JwtDecoderFactory<ClientRegistration> factory) {
// 이 빈은 프로젝트 구성에 따라 다를 수 있어, 아래처럼 직접 NimbusJwtDecoder를 만들기도 합니다.
throw new UnsupportedOperationException("Use one of the concrete examples below");
}
@Bean
public JwtDecoder jwtDecoderFromIssuer(org.springframework.beans.factory.annotation.Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuer) {
NimbusJwtDecoder decoder = JwtDecoders.fromIssuerLocation(issuer);
OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuer);
OAuth2TokenValidator<Jwt> withClockSkew = new DelegatingOAuth2TokenValidator<>(
withIssuer,
new JwtTimestampValidator(Duration.ofSeconds(60))
);
decoder.setJwtValidator(withClockSkew);
return decoder;
}
}
핵심은 JwtTimestampValidator(Duration ...) 입니다. 이 값은 “보안 vs 안정성”의 트레이드오프이므로, 일반적으로 30~120초 사이에서 운영 환경을 보고 결정합니다.
해결 2) 인프라 레벨에서 시간 동기화 보장(근본)
애플리케이션에서 skew를 허용하는 건 완화책이고, 근본은 노드/서버의 시간 동기화입니다.
- NTP 또는 chrony 정상 동작 확인
- 노드 교체/오토스케일 시 시간 튐 여부 확인
- 멀티 AZ, 멀티 클러스터 간 시간 동기화 정책 점검
운영 장애를 10분 내로 좁히는 접근은 다른 인프라 장애에서도 동일합니다. 예를 들어 재시작 루프처럼 “증상은 앱인데 원인은 시스템”인 경우가 많습니다. 관련해서는 systemd 서비스 재시작 루프 10분 진단 가이드도 함께 참고하면 진단 흐름을 잡는 데 도움이 됩니다.
해결 3) 토큰 만료 전략 재검토
- 액세스 토큰 TTL이 너무 짧으면 skew에 민감
nbf를 꼭 써야 하는지 재검토(필요하면 skew를 더 넉넉히)- 모바일/클라이언트에서 오래된 토큰 재사용 빈도 확인
원인 2: 키 롤오버(key rollover)로 인한 서명 검증 실패
키 롤오버가 401을 만드는 전형적인 시나리오
서명 키를 교체하면, 새로운 키로 서명된 JWT는 새 키를 아는 서버만 검증할 수 있습니다. 문제는 롤오버 순간에 다음이 섞이면서 간헐적 401이 됩니다.
- 일부 인스턴스는 JWK 캐시가 갱신되어 새 키를 앎
- 일부 인스턴스는 캐시가 남아 있거나 네트워크 문제로 JWK 갱신 실패
- 또는 리소스 서버가 아예 고정된 공개키/시크릿을 들고 있어 새 키를 모름
이때 클라이언트 입장에서는 “같은 토큰인데 어떤 서버는 되고 어떤 서버는 401”처럼 보입니다.
로그/에러 단서
키 롤오버 문제는 보통 다음 류의 메시지로 드러납니다.
Invalid signatureSigned JWT rejected: Another algorithm expected, or no matching key(s) foundNo matching key(s) found(특히kid관련)
해결 1) kid 기반 멀티 키 검증(JWK Set)으로 전환
가장 안전한 방식은 Authorization Server가 JWK Set 엔드포인트를 제공하고, Resource Server는 이를 통해 키를 가져와 kid로 선택해 검증하는 것입니다.
Spring Boot 설정 예:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com
# 또는 jwk-set-uri를 직접 지정
# jwk-set-uri: https://auth.example.com/.well-known/jwks.json
이 구조에서는 롤오버 시에도 JWK Set에 이전 키와 새 키를 일정 기간 함께 노출하면 무중단 전환이 가능합니다.
해결 2) 롤오버 시 “이전 키 유지 기간”을 둔다
권장 운영 패턴은 다음과 같습니다.
- 새 키 생성
- JWK Set에 이전 키 + 새 키를 함께 게시
- 토큰 발급은 새 키로 전환
- 액세스 토큰 TTL + 안전 버퍼만큼 지난 뒤 이전 키 제거
예를 들어 액세스 토큰 TTL이 15분이면, 최소 15분 이상(현실적으로 30~60분) 이전 키를 유지합니다. 리프레시 토큰까지 JWT로 서명 검증하는 구조라면 유지 기간을 더 길게 잡아야 합니다.
해결 3) JWK 캐시/갱신 실패를 관측 가능하게 만든다
간헐적 401은 “어떤 인스턴스가 어떤 키를 알고 있었나”가 안 보이면 끝까지 못 잡습니다.
권장 체크리스트:
- JWK fetch 실패 로그/메트릭 수집
- 아웃바운드 네트워크(프록시, 방화벽, DNS) 이슈 점검
- 애플리케이션 재기동 시 JWK 초기 로딩 실패 처리(실패 시 기동 실패로 둘지, 재시도할지)
쿠버네티스 환경이라면 네트워크 레벨 오류가 인증 장애로 튀어나오는 경우가 많습니다. gRPC나 프록시 계층에서도 비슷한 “간헐 실패” 패턴이 나타나므로, 네트워크/프록시 관점의 트러블슈팅 감각을 같이 가져가면 좋습니다. 관련해서는 Kubernetes gRPC UNAVAILABLE·RST_STREAM 원인과 Envoy·NGINX 대응도 참고할 만합니다.
Spring Security에서 401을 “원인별”로 응답/로그 분리하기
운영에서는 401이 하나로 뭉개지면 분석이 늦어집니다. AuthenticationEntryPoint를 커스터마이즈해서 원인을 로깅하고, 필요하면 응답 바디에 에러 코드를 내려 디버깅 시간을 줄일 수 있습니다.
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.core.AuthenticationException;
import java.io.IOException;
@Configuration
public class SecurityConfig {
private static final Logger log = LoggerFactory.getLogger(SecurityConfig.class);
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/health").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> {})
.authenticationEntryPoint(loggingEntryPoint())
);
return http.build();
}
@Bean
AuthenticationEntryPoint loggingEntryPoint() {
return (HttpServletRequest request, HttpServletResponse response, AuthenticationException ex) -> {
// ex.getCause() 체인에 JwtValidationException 등이 들어있는 경우가 많습니다.
log.warn("JWT authentication failed. path={}, message={}, cause={}",
request.getRequestURI(), ex.getMessage(), (ex.getCause() != null ? ex.getCause().toString() : "null"));
response.setStatus(401);
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"unauthorized\"}");
};
}
}
이렇게 해두면, “만료”와 “서명 불일치”가 같은 401이라도 로그에서 빠르게 구분됩니다.
운영 체크리스트: clock skew vs key rollover 10분 판별
1) 동일 토큰이 서버마다 성공/실패가 갈리는가
- 그렇다면 키 롤오버 또는 인스턴스별 캐시/설정 불일치 가능성이 큼
2) 실패가 특정 시각대에 몰리는가
- 키 교체 배포 직후, 인증 서버 설정 변경 직후라면 키 롤오버 의심
- 노드 교체/오토스케일 직후라면 시간 동기화 이슈도 의심
3) 로그에 시간 관련 문구가 있는가
expired,not yet valid,timestamp is in the future류면 clock skew 가능성이 큼
4) 로그에 서명/키 관련 문구가 있는가
Invalid signature,no matching key류면 키 롤오버 가능성이 큼
마무리: “간헐적 401”은 보통 분산 시스템의 기본기 문제다
Spring Security JWT 401은 프레임워크 문제가 아니라, 분산 환경에서 흔한 운영 이슈(시간, 키 배포/캐시, 네트워크)가 표면화된 경우가 많습니다.
- clock skew는
JwtTimestampValidator로 완화하고, NTP/chrony로 근본 해결 - 키 롤오버는 JWK Set +
kid기반 멀티 키 검증, 그리고 “이전 키 유지 기간”으로 무중단 전환 - 마지막으로 401을 원인별로 로깅/관측 가능하게 만들어 재발 시 시간을 줄이기
위 세 가지를 갖추면 “가끔 401” 같은 가장 골치 아픈 장애가 대부분 사라집니다.