- Published on
Spring Boot JWT 인증 401 간헐 발생 원인 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 잘 돌고 있고, 같은 토큰으로 어떤 요청은 200인데 어떤 요청은 401이라면 대부분 “토큰이 틀렸다” 보다는 “토큰이 검증되는 경로/환경이 요청마다 달라진다” 쪽을 의심해야 합니다. 특히 Spring Security 필터 체인, 프록시/게이트웨이, 멀티 인스턴스(키/시계/세션 불일치), 비동기 처리에서 SecurityContext 전파 누락이 대표적인 함정입니다.
아래는 Spring Boot(JWT)에서 401이 간헐적으로 발생하는 원인 7가지와, 각 원인별로 바로 적용 가능한 진단/해결 체크리스트입니다.
> 참고: Spring Boot 3 환경에서 SecurityContext 누락으로 발생하는 간헐 401은 아래 글에서도 더 깊게 다룹니다. > - Spring Boot 3에서 가끔 401? SecurityContext 누락 해결
1) 서버 시간/클럭 스큐(clock skew)로 인한 exp/nbf 오판정
JWT 검증에서 exp(만료), nbf(사용 시작), iat(발급 시각) 는 서버 시간이 기준입니다. 노드가 여러 대라면 일부 인스턴스의 시간이 살짝 어긋나도 “가끔” 401이 납니다.
증상
- 동일 토큰이 어떤 인스턴스에서는 통과, 어떤 인스턴스에서는
ExpiredJwtException,JwtException등 - 만료 직전/직후 구간에서 특히 빈번
진단
- 각 인스턴스에서
date, NTP 동기화 상태 확인 - JWT
exp와 서버 로그 타임스탬프 비교
해결
- NTP/Chrony로 시간 동기화 강제
- 검증 시 clock skew 허용(권장: 수십 초~1분 수준)
@Bean
JwtDecoder jwtDecoder() {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(secretKey()).build();
// exp/nbf 검증에 여유를 둬서 경계 구간의 간헐 실패를 줄임
OAuth2TokenValidator<Jwt> withSkew =
new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithClockSkew(Duration.ofSeconds(60))
);
decoder.setJwtValidator(withSkew);
return decoder;
}
2) 멀티 인스턴스에서 서명 키/키 롤링 불일치(Secret/KS mismatch)
운영에서 가장 흔한 원인 중 하나입니다. 배포/롤링 업데이트 중 일부 파드만 이전 키를 들고 있거나, 환경 변수/시크릿 마운트가 일부 노드에서 갱신되지 않으면 로드밸런서가 트래픽을 섞는 순간 간헐 401이 발생합니다.
증상
- 특정 파드로 라우팅될 때만 401
- 로그에
InvalidSignatureException,SignatureException류
진단
- 파드별로
JWT_SECRET(혹은 keystore) 해시를 로그로 남겨 비교 kid(Key ID) 사용 여부 확인
해결
- JWK/JWKS + kid 기반으로 키 롤링(다중 키 허용)
- 시크릿 배포 전략: 새 키 배포 → 구 키/신 키 동시 허용 → 구 키 폐기
// 예시: kid 기반으로 여러 키를 허용하는 구조(개념 코드)
public Key resolveKey(String kid) {
return keyStore.get(kid); // 현재/이전 키를 모두 보관
}
3) 프록시/게이트웨이에서 Authorization 헤더가 간헐적으로 누락/변조
Nginx/ALB/API Gateway/WAF/Service Mesh에서 Authorization 헤더를 전달하지 않거나, 특정 조건(캐시, 리다이렉트, CORS preflight, 특정 경로 룰)에서만 제거하는 경우가 있습니다. 이때 애플리케이션 입장에서는 “토큰이 없는 요청”이 되어 401이 납니다.
증상
- 서버 로그에서
Authorization이 null인 요청이 섞여 있음 - 브라우저/모바일에서만, 혹은 특정 엔드포인트에서만 간헐 발생
진단
- 게이트웨이/프록시 access log에 요청 헤더 로깅(민감정보 마스킹 필수)
- Spring에서 필터로 Authorization 존재 여부를 샘플링 로그
@Component
public class AuthHeaderProbeFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String auth = request.getHeader("Authorization");
if (auth == null) {
// 토큰 본문을 찍지 말고, 존재 여부/경로/요청ID만
logger.warn("Missing Authorization header. uri={}, requestId={}",
request.getRequestURI(), request.getHeader("X-Request-Id"));
}
filterChain.doFilter(request, response);
}
}
해결
- Nginx:
proxy_set_header Authorization $http_authorization; - ALB/Ingress: 헤더 전달 정책, WAF 룰 점검
- CORS: 클라이언트가 Authorization을 보내도록
allowedHeaders포함
4) Spring Security 필터 체인/매처 설정 불일치로 “가끔” 인증이 스킵되거나 덮어씌워짐
여러 개의 SecurityFilterChain을 두거나, requestMatchers 우선순위가 꼬이면 특정 경로/메서드에서만 다른 체인이 타서 인증이 다르게 동작합니다. 특히 /api/**와 /api/v1/** 같은 중첩 패턴, DispatcherType.ERROR 처리 등이 섞이면 재현이 어렵습니다.
증상
- 같은 컨트롤러인데 URL/쿼리/슬래시 유무에 따라 401
- 특정 HTTP 메서드에서만 401
진단
org.springframework.security디버그 로그 활성화- 실제 어떤 FilterChain이 선택되는지 확인
logging.level.org.springframework.security=DEBUG
해결
- 체인을 최소화하고, 매처를 명확히 분리
securityMatcher()와requestMatchers()사용 시 우선순위 의도대로 정렬
@Bean
SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
return http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()))
.build();
}
5) 비동기/스레드 전환에서 SecurityContext 전파 누락
@Async, CompletableFuture, 스케줄러, Reactor/웹플럭스 혼용 등으로 스레드가 바뀌면 SecurityContext가 사라질 수 있습니다. 이 경우 “어떤 요청은 된다”가 아니라 “어떤 코드 경로에서만” 401/403이 튀는 형태로 나타나며, 특히 컨트롤러 내부에서 비동기 작업을 시작할 때 자주 발생합니다.
증상
- 동기 호출은 200인데, 비동기 로직을 타면 401/403
- 로그에 principal이 null
해결
- Spring Security의 Delegating 래퍼 사용(스레드풀에 SecurityContext 전달)
@Bean
public AsyncTaskExecutor applicationTaskExecutor() {
ThreadPoolTaskExecutor delegate = new ThreadPoolTaskExecutor();
delegate.setCorePoolSize(10);
delegate.initialize();
return new DelegatingSecurityContextAsyncTaskExecutor(delegate);
}
보다 구체적인 케이스(특히 Spring Boot 3, 필터/컨텍스트 누락)는 아래 글이 실전적입니다.
6) 토큰 갱신(Refresh) 경쟁 조건: 클라이언트가 “옛 토큰”으로 레이스
모바일/SPA에서 흔합니다. 동시에 여러 API 요청이 나가고, 만료가 임박해 refresh가 발생하면:
- 어떤 요청은 새 토큰으로 성공
- 어떤 요청은 갱신 직전의 액세스 토큰으로 서버에 도착 → 401
서버가 refresh 시점에 기존 토큰을 즉시 블랙리스트 처리하거나, 세션/리프레시 토큰 정책이 빡빡하면 더 자주 보입니다.
진단
- 401 직전/직후에 refresh 호출이 있었는지 클라이언트 로그 확인
- 서버에서
jti(토큰 ID) 기반으로 거부 사유(만료/폐기/블랙리스트) 분류 로깅
해결
- 클라이언트: refresh 단일화(동시 refresh 방지), 요청 큐잉 후 재시도
- 서버: 짧은 그레이스 기간(예: 10~30초) 허용 또는 회전 정책 재검토
// (개념) SPA에서 refresh 단일화 패턴
let refreshing = null;
async function getAccessToken() {
if (!refreshing) {
refreshing = refreshToken().finally(() => (refreshing = null));
}
return refreshing;
}
7) 네트워크/인프라 간헐 장애로 인한 “검증 의존성” 실패(JWKS/Redis/DB)
JWT 자체는 stateless지만, 운영에서는 종종 다음 의존성이 붙습니다.
- RS256에서
JwtDecoder가 JWKS 엔드포인트를 조회 - 토큰 폐기/블랙리스트를 Redis에서 확인
- 사용자 상태/권한을 DB에서 조회
이 의존성이 간헐적으로 타임아웃/연결 실패하면, 구현에 따라 401로 매핑되거나(혹은 500이어야 할 것이 401로) 인증 실패처럼 보입니다.
증상
- 401이지만 로그를 보면 사실상
ConnectTimeout,UnknownHost,ReadTimeout등 - 특정 AZ/노드에서만 빈번
진단
- 401 응답 시 원인 예외를 분리 로깅(인증 실패 vs 의존성 실패)
- 네트워크 간헐 이슈는 쿠버네티스/EKS에서 특히 자주 보이므로 egress/DNS도 함께 점검
관련해서 인프라 단의 간헐 장애를 추적하는 글도 함께 참고하면 좋습니다.
해결
- JWKS는 캐시/리트라이(지수 백오프) 적용, 타임아웃 튜닝
- 블랙리스트 조회 실패는 401로 숨기지 말고 5xx로 분리(관측 가능성)
// JwtDecoder에 네트워크 타임아웃을 명시(예: RestOperations 기반 커스터마이징)
@Bean
JwtDecoder jwtDecoder(RestTemplateBuilder builder) {
RestOperations rest = builder
.setConnectTimeout(Duration.ofSeconds(2))
.setReadTimeout(Duration.ofSeconds(2))
.build();
NimbusJwtDecoder decoder = NimbusJwtDecoder
.withJwkSetUri("https://issuer.example.com/.well-known/jwks.json")
.restOperations(rest)
.build();
return decoder;
}
재현·관측을 위한 공통 체크리스트(실전)
간헐 401은 “원인별로 증거를 남기는 설계”가 핵심입니다.
1) 401을 사유별로 구분 로깅
- 토큰 없음
- 서명 실패
- 만료(exp)
- nbf/iat 문제
- 키 조회 실패(JWKS)
- 블랙리스트/세션 조회 실패
2) 요청 상관관계 ID
X-Request-Id를 인입/생성해 모든 로그에 포함- LB/Ingress 로그와 애플리케이션 로그를 한 줄로 연결
3) 어떤 인스턴스가 401을 냈는지
- 응답 헤더에
X-Served-By(파드명) 같은 식별자를 넣어, “특정 파드만 401”을 즉시 확인
@Component
public class ServedByHeaderFilter extends OncePerRequestFilter {
@Value("${HOSTNAME:unknown}")
String hostname;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
response.setHeader("X-Served-By", hostname);
filterChain.doFilter(request, response);
}
}
마무리
Spring Boot JWT의 간헐 401은 대개 (1) 시간, (2) 키, (3) 헤더 전달, (4) 필터 체인, (5) 컨텍스트 전파, (6) refresh 레이스, (7) 의존성 네트워크 중 하나로 수렴합니다.
가장 빠른 접근은 “토큰을 더 자세히 파싱”이 아니라, 401을 사유별로 분류하고(로그/메트릭), 어떤 인스턴스/경로/프록시 구간에서 Authorization이 깨지는지를 먼저 좁히는 것입니다. 그 다음에야 exp/nbf 스큐, 키 롤링, 비동기 컨텍스트 전파 같은 정밀한 처방이 정확히 들어갑니다.