Published on

Spring Security JWT 401 원인 - Bearer·aud·clock skew

Authors

서버는 분명 토큰을 발급했고, 클라이언트도 토큰을 붙여 보냈는데 Spring Security가 401 Unauthorized를 반환하는 경우가 있습니다. 특히 OAuth2 Resource Server + JWT 조합에서는 에러 메시지가 애매하게 보이거나(로그 레벨에 따라) 원인이 한 번에 드러나지 않습니다.

이 글에서는 실무에서 가장 빈도가 높은 3가지 축인 Bearer 헤더 형식, aud(Audience) 검증, **clock skew(서버 시간 오차)**를 중심으로, 어디를 어떻게 확인하고 어떤 설정/코드로 해결하는지 정리합니다.

> 참고로, 인증 실패(401) 이슈는 인프라/네트워크 문제와 섞여 보일 때가 많습니다. Kubernetes/EKS에서 관측/진단이 어려울 때는 Kubernetes apiserver i/o timeout 원인과 해결 같은 운영 관점 체크리스트도 같이 보면 원인 분리가 빨라집니다.

1) 401을 먼저 “정확히” 관측하기

401의 원인을 잡으려면, 우선 Spring Security가 왜 거절했는지 로그로 드러나게 만들어야 합니다.

(1) Security 디버그 로그 켜기

application.yml에 아래를 추가합니다.

logging:
  level:
    org.springframework.security: DEBUG
    org.springframework.security.oauth2: DEBUG

이후 요청을 다시 보내면, 다음과 같은 단서가 나옵니다.

  • Bearer token is malformed → Authorization 헤더/토큰 포맷 문제
  • Invalid audience / aud claim is not valid → aud 검증 불일치
  • Jwt expired at ... 또는 Jwt used before ... → 시간/만료/clock skew

(2) /error 응답만 보고 판단하지 않기

프록시/게이트웨이/Ingress가 401을 변형할 수 있습니다. 예를 들어 Nginx/Envoy가 WWW-Authenticate 헤더를 제거하거나, CORS 설정 때문에 브라우저에서 다른 증상으로 보이기도 합니다. 스트리밍/프록시 환경에서 오류가 증폭되는 케이스는 LLM SSE 스트리밍 499 502 급증과 응답 끊김을 잡는 프록시 튜닝 체크리스트처럼 “중간 레이어 관측”을 같이 하는 게 좋습니다.

2) Bearer: Authorization 헤더 형식/전달 문제

가장 흔한 실수는 토큰은 있는데 Spring Security가 토큰을 ‘발견’하지 못하는 상황입니다.

(1) 올바른 헤더 형식

정답은 아래 한 줄입니다.

Authorization: Bearer <JWT>

다음은 자주 틀리는 패턴입니다.

  • Authorization: bearer <JWT> (대소문자는 보통 허용되지만, 중간 프록시/라이브러리에서 깨지는 경우가 있음)
  • Authorization: Bearer<JWT> (공백 누락)
  • Authorization: JWT <JWT> (스킴이 Bearer가 아님)
  • Authorization: Bearer "<JWT>" (따옴표 포함)

(2) curl로 재현/검증

브라우저/프론트 코드를 의심하기 전에 curl로 서버 단에서 재현해 봅니다.

TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."

curl -i \
  -H "Authorization: Bearer ${TOKEN}" \
  http://localhost:8080/api/me

여기서도 401이면 서버 설정/검증 문제이고, curl은 200인데 프론트만 401이면 CORS/프록시/헤더 전달 문제일 확률이 큽니다.

(3) 프록시가 Authorization을 제거하는 케이스

  • Nginx에서 proxy_set_header Authorization $http_authorization; 누락
  • API Gateway에서 기본적으로 Authorization 헤더를 전달하지 않도록 설정된 경우
  • CORS preflight 이후 실제 요청에 헤더가 붙지 않는 프론트 버그

Kubernetes 환경이라면 Ingress/Service Mesh/Sidecar가 개입할 수 있으니, Pod에 직접 포트포워딩해서 비교하는 것도 좋습니다.

3) aud(Audience) 불일치: “토큰은 진짜인데 내 서비스용이 아님”

JWT의 aud는 “이 토큰의 대상”을 의미합니다. IdP(예: Keycloak, Auth0, Cognito, Azure AD)가 발급한 토큰이더라도, 우리 리소스 서버가 기대하는 audience와 다르면 Spring Security는 401을 반환할 수 있습니다.

(1) 토큰을 디코드해서 aud 확인

JWT는 Base64URL로 인코딩되어 있어 페이로드를 쉽게 볼 수 있습니다.

# macOS/Linux: jq가 있다고 가정
TOKEN="eyJhbGciOi..."

python - <<'PY'
import os, json, base64

t = os.environ.get('TOKEN')
parts = t.split('.')
payload = parts[1] + '=' * (-len(parts[1]) % 4)
print(json.dumps(json.loads(base64.urlsafe_b64decode(payload)), indent=2))
PY

여기서 aud가 다음 중 어떤 형태인지 확인합니다.

  • 문자열: "aud": "api://my-service"
  • 배열: "aud": ["my-service", "account"]

서비스가 기대하는 값과 정확히 일치해야 합니다.

(2) Spring Security에서 aud 검증을 명시적으로 추가하기

Spring Security의 기본 설정만으로는 issuer/서명 검증만 하고 audience를 엄격히 보지 않는 구성도 있고(환경/버전에 따라 다름), 반대로 프레임워크/라이브러리 조합에 의해 aud 검증이 걸려 401이 나는 경우도 있습니다. 중요한 건 우리 서비스가 기대하는 aud를 코드로 고정해 두는 것입니다.

아래는 NimbusJwtDecoder에 audience validator를 결합하는 예시입니다.

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.oauth2.jwt.*;
import org.springframework.security.oauth2.core.*;
import org.springframework.security.oauth2.core.validator.*;

import java.util.List;

@Configuration
public class SecurityConfig {

  @Bean
  SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    return http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/public/**").permitAll()
            .anyRequest().authenticated()
        )
        .oauth2ResourceServer(oauth2 -> oauth2
            .jwt(jwt -> jwt.decoder(jwtDecoder()))
        )
        .build();
  }

  @Bean
  JwtDecoder jwtDecoder() {
    String jwkSetUri = "https://idp.example.com/.well-known/jwks.json";
    NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();

    // issuer 검증(권장)
    OAuth2TokenValidator<Jwt> issuerValidator =
        JwtValidators.createDefaultWithIssuer("https://idp.example.com/");

    // aud 검증(직접 정의)
    OAuth2TokenValidator<Jwt> audienceValidator = jwt -> {
      Object aud = jwt.getClaims().get("aud");
      boolean ok = false;

      if (aud instanceof String s) {
        ok = s.equals("api://my-service");
      } else if (aud instanceof List<?> list) {
        ok = list.contains("api://my-service");
      }

      return ok ? OAuth2TokenValidatorResult.success()
                : OAuth2TokenValidatorResult.failure(
                    new OAuth2Error("invalid_token", "Invalid audience", null));
    };

    decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(issuerValidator, audienceValidator));
    return decoder;
  }
}

이렇게 해두면, “우연히 통과/실패”가 아니라 명확한 정책으로 aud를 검증하게 됩니다.

(3) 멀티 리소스 서버/마이크로서비스에서의 팁

  • 마이크로서비스마다 aud를 다르게 두면, 프론트/게이트웨이가 토큰을 서비스별로 받아야 해서 복잡해집니다.
  • 실무에서는 aud = api 같은 공통 값 + scope/roles로 세분화하거나, 게이트웨이에서 토큰 교환(token exchange)을 고려하기도 합니다.

4) clock skew: “토큰이 아직 유효한데 만료/미래로 판정”

JWT에는 보통 다음 시간 관련 클레임이 들어갑니다.

  • exp (만료)
  • nbf (not before)
  • iat (issued at)

서버 시간이 IdP 시간과 어긋나면, 다음 같은 현상이 생깁니다.

  • 막 발급한 토큰인데 nbf 때문에 “아직 사용 불가”
  • 아직 만료 전인데 exp 때문에 “이미 만료”

특히 컨테이너/노드의 시간 동기화(NTP/chrony), VM suspend/resume, 클러스터 노드 교체 시점에서 간헐적으로 터집니다.

(1) 증상 로그

보통 DEBUG 로그에 아래처럼 찍힙니다.

  • Jwt expired at 2026-...
  • Jwt used before 2026-...

(2) 서버 시간부터 확인

컨테이너 내부에서 확인합니다.

date -u
# Kubernetes라면 노드 시간도 함께 확인(접근 가능할 때)

IdP(예: Keycloak) 서버 시간도 함께 확인해야 합니다.

(3) 허용 오차(clock skew) 적용

Spring Security/Nimbus 쪽에서 clock skew(허용 오차)를 줄 수 있습니다. 프로젝트/버전에 따라 API가 조금씩 다르지만, 핵심은 JWT 시간 검증에 오차 범위를 부여하는 것입니다.

아래는 JwtTimestampValidator를 사용해 60초 skew를 허용하는 패턴입니다.

import org.springframework.security.oauth2.jwt.*;
import org.springframework.security.oauth2.core.validator.*;

import java.time.Duration;

@Bean
JwtDecoder jwtDecoder() {
  NimbusJwtDecoder decoder = NimbusJwtDecoder
      .withJwkSetUri("https://idp.example.com/.well-known/jwks.json")
      .build();

  OAuth2TokenValidator<Jwt> withIssuer =
      JwtValidators.createDefaultWithIssuer("https://idp.example.com/");

  JwtTimestampValidator timestampValidator = new JwtTimestampValidator();
  timestampValidator.setClockSkew(Duration.ofSeconds(60));

  decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(withIssuer, timestampValidator));
  return decoder;
}

주의할 점:

  • clock skew는 “문제를 덮는 설정”이 아니라, 분산 환경에서 현실적으로 필요한 완충입니다.
  • 5분, 10분처럼 과도하게 키우면 만료 정책이 무력화될 수 있습니다.
  • 근본적으로는 노드/VM의 시간 동기화가 먼저입니다.

5) 401을 빠르게 분류하는 체크리스트

아래 순서대로 보면 보통 10분 내로 갈라집니다.

(1) 토큰이 서버에 도착했는가?

  • Spring Security 로그에서 BearerTokenAuthenticationFilter가 토큰을 파싱하는지 확인
  • Ingress/Gateway가 Authorization 헤더를 제거하지 않는지 확인

(2) 서명/issuer는 맞는가?

  • issuer-uri 또는 jwk-set-uri가 올바른지
  • kid가 JWKS에 존재하는지(키 롤오버 시 자주 문제)

(3) aud가 기대와 일치하는가?

  • 토큰 payload의 aud 확인
  • 서비스에서 aud validator를 명시했는지

(4) 시간 관련(exp/nbf) 문제인가?

  • 서버/IdP 시간 UTC 기준 비교
  • 필요 시 30~60초 skew 허용

6) (보너스) Spring 설정 예시: issuer-uri 기반으로 깔끔하게

가능하면 jwk-set-uri를 직접 박기보다 issuer-uri를 쓰는 편이 운영에 유리합니다(키 롤오버/메타데이터 변경 대응).

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://idp.example.com/

그리고 위에서 소개한 aud/clock skew 검증은 JwtDecoder 빈을 커스터마이징해서 결합합니다.

7) 마무리: 401은 “보안이 엄격해서”가 아니라 “신호가 부족해서”

Spring Security의 JWT 401은 대부분 아래 셋 중 하나로 수렴합니다.

  1. Bearer 헤더가 없거나 형식이 틀림(또는 프록시가 제거)
  2. aud 불일치(토큰은 유효하지만 내 서비스용이 아님)
  3. clock skew/시간 검증 실패(exp/nbf/iat)

핵심은 디버그 로그로 신호를 확보하고, 토큰을 디코드해 클레임을 확인한 뒤, 필요한 검증(aud)과 완충(clock skew)을 코드로 명시하는 것입니다.

운영 환경이 Kubernetes/EKS라면, 인증 문제처럼 보이는 장애가 실제로는 클러스터 레벨 타임아웃/네트워크 이슈인 경우도 있으니, 필요할 때는 EKS에서 Webhook 타임아웃? Admission 진단법처럼 “플랫폼 관측”도 함께 진행하면 원인 분리가 빨라집니다.