Published on

Nginx HTTPS에서 JWT가 무한 401인 이유 - clock skew·kid

Authors

서버를 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가 막혀 있거나 보안 정책으로 시간 동기화가 지연됨

빠른 진단

  1. API 서버에서 현재 시간을 확인합니다.
date -u
  1. 인증 서버(또는 기준이 되는 시스템)와 비교합니다.

  2. 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 kid
  • signature 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 문제가 커지는 포인트

  1. 백엔드가 JWKS를 가져올 때 SNI/체인 문제로 실패
  • 내부망에서 curl은 되는데 애플리케이션 런타임의 TLS 검증이 실패하는 케이스가 있습니다(루트 CA 번들, 오래된 이미지 등).
  1. Nginx가 JWKS 응답을 캐시/압축/리라이트하면서 이상해짐
  • JWKS를 같은 도메인에서 프록시해 주는 구성에서, 캐시가 과도하게 길거나 stale 응답이 유지되면 키 롤오버 때 전부 401이 됩니다.
  1. issuer가 HTTP에서 HTTPS로 바뀌었는데 토큰의 iss 검증이 엄격한 경우
  • 토큰의 isshttp://...인데 서버는 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를 입력값으로 놓고 하나씩 분리해 보면 대부분 짧은 시간 안에 수습할 수 있습니다.