Published on

Spring Boot JWT 인증 401 간헐 발생 원인 7가지

Authors

서버가 잘 돌고 있고, 같은 토큰으로 어떤 요청은 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에서 JwtDecoderJWKS 엔드포인트를 조회
  • 토큰 폐기/블랙리스트를 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 스큐, 키 롤링, 비동기 컨텍스트 전파 같은 정밀한 처방이 정확히 들어갑니다.