- Published on
Spring Security JWT 401 원인 - Bearer·aud·clock skew
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버는 분명 토큰을 발급했고, 클라이언트도 토큰을 붙여 보냈는데 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은 대부분 아래 셋 중 하나로 수렴합니다.
- Bearer 헤더가 없거나 형식이 틀림(또는 프록시가 제거)
- aud 불일치(토큰은 유효하지만 내 서비스용이 아님)
- clock skew/시간 검증 실패(exp/nbf/iat)
핵심은 디버그 로그로 신호를 확보하고, 토큰을 디코드해 클레임을 확인한 뒤, 필요한 검증(aud)과 완충(clock skew)을 코드로 명시하는 것입니다.
운영 환경이 Kubernetes/EKS라면, 인증 문제처럼 보이는 장애가 실제로는 클러스터 레벨 타임아웃/네트워크 이슈인 경우도 있으니, 필요할 때는 EKS에서 Webhook 타임아웃? Admission 진단법처럼 “플랫폼 관측”도 함께 진행하면 원인 분리가 빨라집니다.