- Published on
Nginx HTTPS에서 JWT가 무한 401인 이유 - clock skew·kid
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버를 HTTPS로 전환한 뒤, 분명히 로그인도 되고 토큰도 발급되는데 API 호출이 전부 401로 떨어지며 “무한 401 루프”처럼 보이는 현상이 종종 발생합니다. 특히 Nginx가 TLS 종료를 담당하고 백엔드(예: Node.js, Spring, Go, Rust)가 JWT를 검증하는 구조에서 잘 터집니다.
이 글은 Nginx HTTPS 환경에서 JWT가 지속적으로 401이 되는 대표 원인 2가지인 clock skew(시간 오차)와 kid(키 식별자) 문제를 중심으로, 실제 운영에서 바로 적용 가능한 진단 체크리스트와 설정 예시를 제공합니다.
관련해서 “시간 오차”가 인증/서명 검증을 망가뜨리는 전형적인 사례는 AWS에서도 자주 보입니다. S3 업로드에서 RequestTimeTooSkewed가 뜨는 것처럼요. 개념적으로 같은 축의 문제이니 함께 참고하면 원인 파악이 빨라집니다: EKS Pod→S3 업로드 403 RequestTimeTooSkewed 해결
증상 패턴: 왜 “무한 401”처럼 보이나
다음 조합이면 사용자 입장에서는 무한 루프가 됩니다.
- 프론트엔드가 API에서 401을 받으면 자동으로 refresh token으로 재발급 시도
- refresh도 401이거나, 재발급은 되는데 다음 요청이 또 401
- 결과적으로 로그인 화면으로 튕기거나, 토큰 재발급 호출만 반복
중요 포인트는 “토큰이 잘못됐다”가 아니라, 검증 과정의 입력(시간/키/헤더)이 기대와 다르게 들어간다는 것입니다.
원인 1: clock skew(서버 시간 오차)로 exp/nbf가 즉시 실패
JWT 검증은 보통 다음 클레임을 확인합니다.
exp: 만료 시간nbf: Not Before (이 시간 이전에는 유효하지 않음)iat: 발급 시간
여기서 서버 시간이 몇 초에서 수 분만 어긋나도 문제가 됩니다.
- 인증 서버(토큰 발급) 시간은 정확한데
- API 서버(토큰 검증) 시간이 느리거나 빠르면
- 방금 발급한 토큰이 “아직 유효하지 않음” 또는 “이미 만료”로 판정됩니다.
특히 HTTPS 전환과 함께 터지는 이유
HTTPS 자체가 시간을 바꾸진 않지만, 전환 과정에서 흔히 같이 바뀌는 것들이 있습니다.
- 서버 교체/오토스케일로 새 노드가 투입됨(시간 동기화 미완료)
- 컨테이너 베이스 이미지가 바뀌며
tzdata/NTP 구성이 달라짐 - VM/노드에서 NTP가 막혀 있거나 보안 정책으로 시간 동기화가 지연됨
빠른 진단
- API 서버에서 현재 시간을 확인합니다.
date -u
인증 서버(또는 기준이 되는 시스템)와 비교합니다.
JWT의
exp/nbf를 디코딩해 실제 값이 “지금”과 얼마나 차이 나는지 봅니다.
# 토큰 페이로드(2번째 세그먼트) 확인용: 로컬에서만 사용 권장
python3 - << 'PY'
import base64, json, sys
t = sys.stdin.read().strip().split('.')
p = t[1] + '=' * (-len(t[1]) % 4)
print(json.dumps(json.loads(base64.urlsafe_b64decode(p)), indent=2))
PY
해결 1) 노드/VM 시간 동기화 강제
Ubuntu 계열이면 보통 systemd-timesyncd 또는 chrony를 씁니다.
# 상태 확인
timedatectl status
# NTP 활성화
sudo timedatectl set-ntp true
chrony 사용 예:
sudo apt-get update
sudo apt-get install -y chrony
sudo systemctl enable --now chrony
chronyc tracking
Kubernetes/EKS라면 “노드” 시간이 핵심입니다. 컨테이너 내부에서 뭘 해도 호스트 시간이 틀리면 같이 틀어지는 경우가 많습니다.
해결 2) JWT 검증에 허용 오차(leeway)를 둔다
운영에서는 수 초 수준의 오차는 현실적으로 생길 수 있습니다. 라이브러리에서 leeway(또는 clockTolerance)를 설정하세요.
Node.js jsonwebtoken 예:
import jwt from 'jsonwebtoken';
jwt.verify(token, publicKey, {
algorithms: ['RS256'],
clockTolerance: 10, // seconds
});
Java jjwt 예:
Jwts.parserBuilder()
.setAllowedClockSkewSeconds(10)
.setSigningKey(publicKey)
.build()
.parseClaimsJws(token);
주의: leeway는 “면죄부”가 아니라 시간 동기화가 정상이라는 전제에서의 안전장치로만 쓰는 게 좋습니다.
원인 2: kid 기반 키 선택(JWKS) 문제로 서명 검증 실패
RS256/ES256처럼 비대칭키 기반 JWT는 보통 헤더에 kid가 들어갑니다.
kid: Key ID, 어떤 공개키로 검증해야 하는지 식별- 서버는 JWKS(JSON Web Key Set)에서
kid가 같은 키를 찾아 검증
문제는 HTTPS 전환 또는 프록시 구성 변경과 함께 다음이 자주 발생한다는 점입니다.
- JWKS URL 접근이 실패(네트워크, DNS, 방화벽, 프록시)
- 캐시된 JWKS가 갱신되지 않음(키 롤오버 시점)
kid가 바뀌었는데 백엔드가 이전 키만 알고 있음- 멀티 테넌트/멀티 issuer 환경에서 잘못된 JWKS를 조회
이 경우 대부분 백엔드 로그에는 이런 류가 남습니다.
no matching key/unable to find a signing key that matches kidsignature verification failed
진단 1) 토큰 헤더에서 kid 확인
python3 - << 'PY'
import base64, json, sys
t = sys.stdin.read().strip().split('.')
h = t[0] + '=' * (-len(t[0]) % 4)
print(json.dumps(json.loads(base64.urlsafe_b64decode(h)), indent=2))
PY
여기서 kid 값을 확보합니다.
진단 2) JWKS에서 해당 kid가 실제로 존재하는지 확인
curl -sS https://YOUR_ISSUER_DOMAIN/.well-known/jwks.json | head
가장 확실한 방법은 JWKS 전체에서 kid를 찾는 것입니다.
curl -sS https://YOUR_ISSUER_DOMAIN/.well-known/jwks.json \
| python3 - << 'PY'
import json, sys
jwks=json.load(sys.stdin)
print([k.get('kid') for k in jwks.get('keys', [])])
PY
- 토큰의
kid가 목록에 없다면: 키 롤오버/환경 불일치/issuer 불일치 가능성이 큽니다. - 목록에 있는데도 검증 실패한다면: 잘못된 issuer로 JWKS를 가져오거나, 캐시/프록시가 오래된 JWKS를 주는 가능성이 큽니다.
HTTPS 전환 시 kid 문제가 커지는 포인트
- 백엔드가 JWKS를 가져올 때 SNI/체인 문제로 실패
- 내부망에서
curl은 되는데 애플리케이션 런타임의 TLS 검증이 실패하는 케이스가 있습니다(루트 CA 번들, 오래된 이미지 등).
- Nginx가 JWKS 응답을 캐시/압축/리라이트하면서 이상해짐
- JWKS를 같은 도메인에서 프록시해 주는 구성에서, 캐시가 과도하게 길거나 stale 응답이 유지되면 키 롤오버 때 전부 401이 됩니다.
- issuer가 HTTP에서 HTTPS로 바뀌었는데 토큰의
iss검증이 엄격한 경우
- 토큰의
iss가http://...인데 서버는https://...만 허용하면 401이 납니다. - 이건
kid문제처럼 보이기도 합니다(검증 단계에서 먼저 issuer를 튕기면 서명 검증까지 못 감).
Nginx에서 꼭 확인할 헤더/프록시 설정
JWT 자체는 보통 Authorization: Bearer ...로 전송됩니다. Nginx에서 이 헤더가 백엔드로 전달되지 않으면, 백엔드는 토큰이 없는 요청으로 판단해 401을 반환합니다.
다음은 가장 기본적인 안전 설정입니다.
server {
listen 443 ssl;
server_name api.example.com;
location / {
proxy_pass http://backend_upstream;
# 인증 헤더 전달
proxy_set_header Authorization $http_authorization;
# 원본 정보 전달(issuer/리다이렉트/절대 URL 생성에 영향)
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# keepalive/업그레이드가 필요한 경우(웹소켓 등)
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
핵심은 proxy_set_header Authorization $http_authorization; 입니다.
- 어떤 환경에서는 기본으로 전달되기도 하지만, 중간에 다른 설정(예:
underscores_in_headers,proxy_set_header재정의)이 끼면 누락되는 일이 있습니다. - CDN/WAF/ALB가 앞에 있으면 거기서도
Authorization을 제거하는 정책이 있는지 확인해야 합니다.
인그레스/로드밸런서 계층 문제로 5xx가 나면 원인이 더 넓어지는데, 그때는 다음 글의 체크리스트가 도움이 됩니다: EKS에서 ALB Ingress 502 Bad Gateway 원인 9가지
kid/JWKS 캐시 전략: “키 롤오버 순간”을 견디기
운영에서 가장 많이 겪는 시나리오는 이겁니다.
- IdP가 키를 롤오버함(새
kid로 토큰 발급 시작) - 백엔드는 여전히 이전 JWKS를 캐시하고 있음
- 새 토큰이 들어오는 순간부터 전부 401
해결책은 “JWKS를 매 요청마다 가져오기”가 아니라, 적절한 캐시 + 실패 시 재조회입니다.
Node.js(jose) 예: 원격 JWKS + 쿨다운/타임아웃
import { jwtVerify, createRemoteJWKSet } from 'jose';
const JWKS = createRemoteJWKSet(new URL('https://YOUR_ISSUER_DOMAIN/.well-known/jwks.json'), {
timeoutDuration: 3000,
cooldownDuration: 30_000,
});
export async function verify(token) {
const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
issuer: 'https://YOUR_ISSUER_DOMAIN/',
audience: 'YOUR_AUDIENCE',
clockTolerance: 10,
});
return { payload, protectedHeader };
}
cooldownDuration는 JWKS를 너무 자주 다시 가져오는 것을 방지합니다.- 키가 없어서 실패하는 경우(특히
kid미매칭)는 재조회 트리거를 고려하세요(라이브러리/구현에 따라 다름).
Nginx로 JWKS를 프록시한다면 캐시를 보수적으로
JWKS를 /.well-known/jwks.json 같은 경로로 Nginx가 프록시하는 구성이라면, 캐시 TTL을 과도하게 길게 잡지 마세요.
proxy_cache_path /var/cache/nginx/jwks levels=1:2 keys_zone=jwks_cache:10m max_size=100m inactive=10m use_temp_path=off;
server {
listen 443 ssl;
location = /.well-known/jwks.json {
proxy_pass https://issuer.example.com/.well-known/jwks.json;
proxy_cache jwks_cache;
proxy_cache_valid 200 1m;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
add_header X-Cache-Status $upstream_cache_status;
}
}
- 키 롤오버 주기를 모르면
1m처럼 짧게 시작하고, 트래픽/IdP 정책에 맞춰 조정합니다. X-Cache-Status로 캐시 히트 여부를 관찰할 수 있어 장애 시 진단이 쉬워집니다.
체크리스트: 401을 “빠르게” 원인 분리하는 순서
1) 백엔드가 실제로 토큰을 받는가
- 백엔드에서 요청 헤더 로깅(민감정보 마스킹 필수)
- Nginx에서
Authorization전달 설정 확인
2) 시간 문제인가
- API 서버
date -u확인 - JWT의
exp/nbf확인 - 검증 leeway를 임시로 늘렸을 때 해결되는지 확인(원인 확정용)
3) kid 문제인가
- 토큰 헤더의
kid확인 - JWKS에서 동일
kid존재 여부 확인 - 키 롤오버 직후인지 확인
4) issuer/audience 불일치인가
- HTTPS 전환으로
iss가 바뀌었는지 확인 - 멀티 도메인 환경에서
aud가 기대값과 다른지 확인
마무리: HTTPS는 계기일 뿐, 본질은 “검증 입력”이다
Nginx에서 HTTPS를 적용한 뒤 JWT가 무한 401이 되는 현상은, 대부분 다음 두 축으로 정리됩니다.
clock skew: 검증 서버 시간 오차로exp/nbf가 즉시 실패kid/JWKS: 키 선택 실패 또는 키 롤오버/캐시로 인한 서명 검증 실패
여기에 Nginx 프록시 계층의 Authorization 헤더 전달, X-Forwarded-Proto 같은 원본 정보 전달이 겹치면 “갑자기 HTTPS에서만 터지는” 것처럼 보입니다.
운영에서는 401을 단순히 인증 실패로만 보지 말고, 시간, 키, 헤더, issuer/audience를 입력값으로 놓고 하나씩 분리해 보면 대부분 짧은 시간 안에 수습할 수 있습니다.