- Published on
Nginx에서 JWT 401 간헐 발생 - 시계오차 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버는 멀쩡한데 사용자 입장에서는 “가끔” 로그인이 풀리거나 API가 401 Unauthorized를 뱉는 문제가 가장 까다롭습니다. 특히 Nginx가 앞단에 있고, 백엔드에서 JWT를 검증하는 구조에서 간헐적인 401이 발생한다면, 네트워크나 캐시보다 먼저 시계 오차(clock skew) 를 의심해야 합니다.
이 글에서는 JWT의 exp, nbf, iat 클레임이 어떤 방식으로 401을 유발하는지, Nginx 환경에서 왜 “간헐적”으로 보이는지, 그리고 운영에서 재발을 막기 위한 시간 동기화 + 허용 오차 + 로깅/추적까지 한 번에 정리합니다.
참고로 Ingress Nginx를 쓰는 환경이라면, 요청 크기나 버퍼 설정 문제로도 4xx가 섞여 보일 수 있습니다. 관련 튜닝은 EKS NGINX Ingress 400·413 해결 - body·버퍼 튜닝도 함께 확인해두면 좋습니다.
증상 패턴: “같은 토큰인데 어떤 서버는 통과, 어떤 서버는 401”
시계 오차 이슈는 보통 다음 패턴으로 나타납니다.
- 동일한 사용자/동일한 토큰이 어떤 요청에서는 성공, 어떤 요청에서는
401. - 재시도하면 통과하기도 함(로드밸런서가 다른 인스턴스로 라우팅).
- 토큰 만료(
exp)까지 시간이 남았는데도 만료로 처리되거나, - 발급 직후 토큰이 “아직 유효하지 않음”(
nbf)처럼 거절됨.
즉, 문제는 토큰 자체가 아니라 검증하는 서버의 현재 시각이 다를 때 발생합니다.
JWT에서 시간 관련 클레임이 401을 만드는 방식
JWT는 보통 다음 클레임을 씁니다.
exp: 만료 시각(Unix time)nbf: 이 시각 이후부터 유효iat: 발급 시각
검증 라이브러리는 내부적으로 대략 이런 로직을 수행합니다.
- 현재 시각이
exp를 지났으면 만료 - 현재 시각이
nbf보다 이전이면 아직 유효하지 않음 - (옵션)
iat가 미래이면 비정상 토큰으로 간주
여기서 현재 시각이 서버별로 다르면 같은 토큰이 서버마다 다르게 판정됩니다.
왜 “간헐적”으로 보일까
Nginx 자체는 JWT의 exp를 신경 쓰지 않더라도, 일반적인 구조는 다음 중 하나입니다.
- Nginx는 프록시 역할만 하고, 백엔드 앱에서 JWT 검증
- Nginx Ingress에서
auth_request로 외부 인증 서비스에 위임 - Nginx에 JWT 검증 모듈을 붙여 Nginx 레벨에서 검증
어느 쪽이든, 로드밸런싱으로 요청이 여러 노드/파드/인스턴스에 분산되면 시계가 빠른 서버로 간 요청만 401이 됩니다. 그래서 “간헐적”으로 보입니다.
1단계: 진짜 원인이 시계 오차인지 확인하는 방법
앱 로그에 “현재 시각”과 “클레임”을 함께 남겨라
가장 빠른 확인은 401이 난 시점에 다음을 로그로 남기는 겁니다.
- 서버가 인식한 현재 시각(UTC)
- 토큰의
exp,nbf,iat - 판정 결과(예:
expired,not_active_yet) - 요청이 도달한 인스턴스 식별자(hostname, pod name)
예시(Node.js, Express, jsonwebtoken 기준):
import jwt from "jsonwebtoken";
function auth(req, res, next) {
const token = (req.headers.authorization || "").replace("Bearer ", "");
try {
const now = Math.floor(Date.now() / 1000);
const decoded = jwt.decode(token, { complete: true });
// 검증은 실제로 verify에서 수행
const payload = jwt.verify(token, process.env.JWT_PUBLIC_KEY, {
algorithms: ["RS256"],
// clockTolerance는 뒤에서 자세히 설명
});
req.user = payload;
return next();
} catch (e) {
// decode는 서명 검증 없이도 가능하므로, 실패 시에도 exp 확인용으로만 사용
const now = Math.floor(Date.now() / 1000);
const decoded = jwt.decode(token) || {};
console.error("jwt_auth_failed", {
now,
exp: decoded.exp,
nbf: decoded.nbf,
iat: decoded.iat,
server: process.env.HOSTNAME,
err: String(e.message || e),
});
return res.status(401).json({ message: "Unauthorized" });
}
}
이 로그에서 now가 exp보다 큰데 사용자는 “아직 만료가 아닌데요”라고 한다면, 그 서버 시간이 앞서 있는 겁니다.
서버 간 시간 차이를 직접 측정
리눅스 인스턴스라면 다음으로 빠르게 확인할 수 있습니다.
# 현재 시간(UTC) 확인
date -u
# systemd-timesyncd / chrony 상태 확인(환경에 따라 다름)
timedatectl status
# chrony 사용 시
chronyc tracking
chronyc sources -v
Kubernetes라면 노드별로 확인하는 게 핵심입니다. 파드 내부 시간은 결국 노드 시간을 따라가므로, 노드 단에서 drift가 나는지 봐야 합니다.
2단계: 해결 전략 3가지(권장 우선순위)
1) 근본 해결: 모든 노드의 시간 동기화(NTP) 보장
가장 좋은 해결은 시계를 맞추는 것입니다.
- 클라우드 VM: 기본 NTP가 꺼져 있거나 보안 정책으로 막히는 경우가 있습니다.
- 온프레미스: 사내 NTP 서버 장애나 방화벽 정책으로 drift가 커질 수 있습니다.
- 컨테이너: 컨테이너에서 시간을 맞추는 게 아니라 호스트에서 맞춰야 합니다.
systemd 기반이라면 보통 다음 조합 중 하나입니다.
systemd-timesyncdchrony
chrony를 많이 쓰는 이유는 네트워크가 불안정한 환경에서도 드리프트 보정이 안정적이기 때문입니다.
예시(우분투에서 chrony 설치):
sudo apt-get update
sudo apt-get install -y chrony
sudo systemctl enable chrony
sudo systemctl restart chrony
chronyc tracking
운영 팁:
- NTP 서버를 단일로 두지 말고 복수로 설정
- 드리프트가 특정 임계치 이상이면 알람(예: 1초, 5초)
- 노드 교체/스케일링 시에도 동일 설정이 적용되도록 IaC로 고정
2) 실무 타협: JWT 검증에 허용 오차(clock tolerance) 적용
분산 시스템에서 완벽한 시간 일치는 어렵습니다. 그래서 많은 JWT 라이브러리는 허용 오차 옵션을 제공합니다.
clockTolerance또는leeway같은 이름- 보통 초 단위
Node.js jsonwebtoken 예시:
jwt.verify(token, process.env.JWT_PUBLIC_KEY, {
algorithms: ["RS256"],
clockTolerance: 10, // 10초 허용
});
Python pyjwt 예시:
import jwt
payload = jwt.decode(
token,
public_key,
algorithms=["RS256"],
leeway=10, # 10초 허용
)
권장값은 환경에 따라 다르지만, 보통 5에서 30초 사이가 현실적입니다.
- 너무 작으면 간헐 401이 계속 남음
- 너무 크면 만료된 토큰이 더 오래 통과하는 보안 리스크가 생김
따라서 시간 동기화로 drift를 줄이고, tolerance는 “마지막 안전장치”로 두는 게 좋습니다.
3) 구조 개선: 검증 지점을 단일화하거나, 토큰 발급 정책을 조정
간헐성을 없애려면 “검증 서버가 여러 대”인 상황을 줄여야 합니다.
auth_request를 쓴다면 인증 서비스 자체를 단일화하거나(또는 내부에서 시간 동기화 철저)- 백엔드가 여러 개라면, 최소한 시간 동기화 정책을 동일하게 강제
또한 토큰 발급 시 nbf를 엄격히 쓰는 경우, 클라이언트-서버-서버 간 시계 차이로 발급 직후 실패가 늘어납니다.
nbf를 생략하거나nbf를iat와 동일하게 두되 tolerance를 주거나- 발급 시각을 서버 기준으로만 사용(클라이언트 시간 사용 금지)
Nginx 관점에서의 체크 포인트
Nginx가 JWT를 직접 검증하지 않더라도, 앞단에서 다음 정보가 없으면 원인 파악이 더 어려워집니다.
1) 401을 누가 만들었는지 식별
- Nginx가
401을 만든 건지 - 업스트림(백엔드)이
401을 만든 건지
이를 구분하려면 Nginx access log에 업스트림 상태를 남기는 게 좋습니다.
log_format main_ext '$remote_addr - $request_id [$time_local] '
'"$request" $status $body_bytes_sent '
'upstream_status=$upstream_status '
'upstream_addr=$upstream_addr '
'request_time=$request_time upstream_time=$upstream_response_time';
access_log /var/log/nginx/access.log main_ext;
여기서 $status가 401인데 upstream_status가 비어 있으면 Nginx 레벨에서 응답했을 가능성이 큽니다. 반대로 upstream_status=401이면 백엔드에서 거절한 겁니다.
2) Date 헤더로 서버 시간 힌트 제공
응답에 Date 헤더가 있으면 클라이언트/테스터가 “서버가 인식한 시간”을 간접 확인할 수 있습니다. Nginx는 기본적으로 Date를 넣지만, 프록시 체인에서 제거되는 경우가 있어 확인이 필요합니다.
또는 디버깅 목적으로만 임시 헤더를 추가할 수도 있습니다.
add_header X-Server-Time $time_iso8601 always;
운영 상시 노출은 보안 정책에 따라 제한될 수 있으니, 장애 분석 기간에만 켜는 것을 권장합니다.
Kubernetes 및 EKS에서 특히 자주 터지는 이유
EKS 같은 환경에서는 다음 이유로 시계 이슈가 “갑자기” 커질 수 있습니다.
- 특정 노드 그룹만 NTP 접근이 막힘(보안 그룹, NACL, egress 정책)
- AMI 업데이트로 time sync 데몬이 바뀌었거나 설정이 초기화
- 노드가 장시간 슬립/재개 또는 스팟 중단 후 재프로비저닝
그리고 이런 문제는 로그가 누락되거나 지연되면 더 늦게 발견됩니다. 장애 분석 시 로그 파이프라인도 함께 점검하는 편이 안전합니다. 관련해서는 EKS에서 fluent-bit 로그 누락·지연 원인 9가지도 참고할 만합니다.
재발 방지 체크리스트
관측성(Observability)
- JWT 검증 실패 로그에
now,exp,nbf,iat,server_id포함 - Nginx access log에
upstream_status,upstream_addr포함 - 노드별 시간 오프셋을 메트릭화(가능하면)
운영 설정
- 모든 노드에 NTP 동기화 정책 강제(AMI, UserData, Ansible, Terraform 등)
- drift 임계치 알람
- JWT 검증에
clockTolerance또는leeway적용(최소 범위)
보안과의 균형
- tolerance를 크게 잡기 전에 반드시 drift 원인을 제거
- 만료 시간을 짧게 쓰는 서비스일수록 drift에 민감하므로 NTP가 더 중요
- refresh token이나 세션 전략이 있다면 만료/갱신 UX도 함께 점검
마무리
Nginx 뒤에서 발생하는 간헐적 JWT 401은 “인증이 불안정해서”라기보다, 대부분 시간이라는 전제 조건이 깨졌기 때문입니다.
- 먼저 401의 생성 주체(Nginx vs 업스트림)를 분리하고
- 실패 로그에 현재 시각과 클레임을 함께 남겨 원인을 확정한 뒤
- NTP 동기화로 근본 해결, tolerance로 안전장치를 추가하면
재현하기 어려운 간헐 장애를 높은 확률로 종결할 수 있습니다.