Published on

Nginx JWT 검증 401 - jwks_uri 캐시와 kid 이슈

Authors

서버 앞단에서 JWT를 검증하도록 Nginx(또는 Ingress Nginx)를 구성해두면, 애플리케이션이 토큰을 직접 파싱하지 않아도 되어 일관된 보안 정책을 적용하기 좋습니다. 그런데 운영 중에 특정 시점부터 401이 급증하거나, 일부 사용자/일부 Pod에서만 간헐적으로 401이 발생하는 경우가 있습니다. 이때 로그를 보면 흔히 kid not found, unable to fetch jwks, signature verification failed 같은 힌트가 보이는데, 핵심 원인은 대개 두 가지입니다.

  • jwks_uri에서 내려받는 JWKS(JSON Web Key Set) 캐시가 오래되었거나 갱신 타이밍이 어긋남
  • 토큰 헤더의 kid와 현재 JWKS에 존재하는 키가 불일치(키 롤오버/회전)

이 글은 “왜 401이 나는지”를 추측으로 끝내지 않고, Nginx에서 JWT 검증이 실패하는 경로를 분해해서 재현 가능하게 점검하는 방법을 정리합니다.

401이 터지는 전형적인 시나리오

1) 키 롤오버 직후 kid 불일치

IdP(예: Cognito, Auth0, Keycloak, Azure AD 등)는 주기적으로 서명 키를 회전합니다. 새 키로 서명된 토큰은 새로운 kid를 갖습니다. 그런데 Nginx가 JWKS를 캐시하고 있으면, 캐시에 새 키가 반영되기 전까지는 다음이 발생합니다.

  • 새 토큰의 kid를 Nginx가 찾지 못함
  • 결과적으로 401

특히 다음 조건에서 증상이 더 “간헐적”으로 보입니다.

  • Nginx 인스턴스가 여러 대이고, 캐시 갱신 시점이 각자 다름
  • 일부 인스턴스만 JWKS 갱신에 실패(네트워크, DNS, TLS)
  • 토큰 발급 서버가 롤오버 기간 동안 구키/신키를 혼용

2) jwks_uri fetch 실패 후 “오래된 캐시”로 버팀

JWKS를 갱신하려고 했는데 네트워크 이슈로 실패하면, 구현에 따라 다음 중 하나로 동작합니다.

  • 실패 즉시 401 (보수적)
  • 기존 캐시를 계속 사용 (가용성 우선)

가용성 우선 모드에서는 “어느 순간부터 특정 kid만 401” 같은 형태로 나타납니다. 즉, 구키로 서명된 토큰은 계속 통과하지만 신키 토큰은 계속 실패합니다.

네트워크 이슈는 대개 이런 방향에서 발생합니다.

  • Nginx가 jwks_uri 도메인을 DNS로 못 풂
  • 아웃바운드 egress가 막힘(보안그룹, NACL, 사내 방화벽)
  • 프록시 환경에서 http_proxy/https_proxy 미설정
  • TLS 검증 실패(루트 CA, 중간 인증서 누락)

EKS 같은 환경이라면 egress/DNS는 특히 자주 발목을 잡습니다. 비슷한 네트워크 계층 트러블슈팅 관점은 EKS Pod STS AssumeRole 타임아웃 - NAT·PrivateLink·DNS도 함께 참고하면 좋습니다.

3) 시간 오차로 exp/nbf 검증 실패

JWT 검증은 서명만 확인하는 게 아니라 exp, nbf, iat 같은 클레임을 검사하는 경우가 많습니다. 서버 시간이 틀어져 있으면 정상 토큰도 401이 납니다.

  • NTP 미동기화
  • 컨테이너 노드 시간 드리프트
  • 멀티 리전/하이브리드에서 시간 정책 불일치

시간 오차는 “어떤 서버에서만” 터지는 형태로 나타나기 쉬워서, kid 이슈와 혼동되기도 합니다.

JWT/JWKS 동작을 빠르게 복기하기

JWT는 보통 header.payload.signature 구조이고, 헤더에 kid가 들어갑니다. kid는 “이 토큰을 서명한 키의 식별자”입니다. 검증자는 다음 순서로 움직입니다.

  1. JWT 헤더에서 kid를 읽음
  2. jwks_uri에서 JWKS를 가져오거나 캐시에서 꺼냄
  3. JWKS 안에서 kid가 같은 JWK를 찾음
  4. JWK를 공개키로 변환
  5. 서명 검증 및 클레임 검증

즉 401이 났을 때는 “어느 단계가 실패했는지”를 분리해야 합니다.

재현 가능한 진단 절차(운영에서 바로 쓰는 순서)

1) 실패한 요청의 JWT 헤더에서 kid를 먼저 확인

운영 로그에 토큰 전체를 남기면 보안 사고가 됩니다. 대신 다음처럼 헤더만 안전하게 추출해서 kid를 확인합니다.

TOKEN='eyJ...'

# JWT header만 base64url 디코딩(리눅스/맥에서 동작하도록 약간 보수적으로 작성)
echo "$TOKEN" \
  | awk -F'.' '{print $1}' \
  | tr '_-' '/+' \
  | awk '{pad=length($0)%4; if(pad==2) $0=$0"=="; else if(pad==3) $0=$0"="; print $0}' \
  | base64 -d 2>/dev/null

출력에서 "kid":"..." 값을 확보합니다.

  • kid가 비어있다: IdP 설정 또는 토큰 타입 문제(일부는 kid 없이도 가능하지만, JWKS 기반 검증에서는 보통 필요)
  • kid가 존재한다: 다음 단계로 JWKS에서 해당 kid가 있는지 확인

2) JWKS에서 해당 kid가 실제로 있는지 확인

JWKS_URL='https://idp.example.com/.well-known/jwks.json'
KID='abc123'

curl -fsS "$JWKS_URL" | jq -r --arg kid "$KID" '.keys[] | select(.kid==$kid) | .kid'
  • 출력이 없다: IdP가 아직 키를 게시하지 않았거나(롤오버 타이밍), 잘못된 jwks_uri를 보고 있거나, 토큰이 다른 Issuer에서 발급됨
  • 출력이 있다: Nginx가 JWKS를 못 가져오거나 캐시가 갱신되지 않는 상황일 가능성이 큼

3) iss/aud 불일치도 같이 확인

kid가 맞는데도 401이면, 서명은 맞더라도 정책 검증에서 떨어질 수 있습니다.

# payload 디코딩
echo "$TOKEN" \
  | awk -F'.' '{print $2}' \
  | tr '_-' '/+' \
  | awk '{pad=length($0)%4; if(pad==2) $0=$0"=="; else if(pad==3) $0=$0"="; print $0}' \
  | base64 -d 2>/dev/null | jq

여기서 iss, aud, exp, nbf를 확인하고, Nginx 설정(또는 검증 모듈 설정)의 기대값과 일치하는지 봅니다.

4) Nginx에서 JWKS fetch가 실패하는지 네트워크로 확인

Nginx가 실행되는 동일한 네임스페이스/노드/컨테이너에서 아래를 실행해 JWKS URL에 접근 가능한지 확인합니다.

curl -v --connect-timeout 2 --max-time 5 -fsS "$JWKS_URL" >/dev/null

여기서 다음이 보이면 원인이 명확해집니다.

  • DNS 실패: Could not resolve host
  • TLS 실패: SSL certificate problem
  • egress 차단: connect timeout

Ingress 환경에서 이런 이슈는 401처럼 보이지만 실제로는 “검증에 필요한 키를 못 가져오는 상태”입니다. (상황에 따라 500이나 503이 아닌 401로 매핑되기도 합니다.) Ingress 자체 동작 점검은 EKS Ingress 503인데 Pod 정상일 때 점검 가이드도 도움이 됩니다.

캐시와 kid 문제를 줄이는 설계 포인트

1) JWKS 캐시 TTL을 “키 롤오버 정책”과 맞추기

IdP가 키를 회전하는 주기와, 새 키를 JWKS에 게시해두는 overlap 기간(구키/신키 공존 기간)이 있습니다. 캐시 TTL이 overlap보다 길면, Nginx는 신키를 영영 못 보게 됩니다.

권장 접근은 다음입니다.

  • JWKS 캐시 TTL을 너무 길게 잡지 않기(예: 수시간 단위는 위험)
  • 대신 stale-while-revalidate처럼 “캐시는 짧게, 갱신은 자주”
  • 롤오버 이벤트가 예고되면 강제 캐시 무효화(배포/리로드 트리거)

Nginx에서 JWT를 직접 검증하는 방식은 모듈/제품에 따라 다릅니다.

  • Nginx Plus의 auth_jwt 계열
  • OpenResty(lua)로 JWKS 캐시 구현
  • auth_request로 별도 검증 서비스에 위임(검증 서비스가 JWKS 캐시)

어떤 방식이든 핵심은 같습니다. “JWKS를 어디에 캐시하고, 언제 갱신하며, 실패 시 어떻게 동작하는가”를 문서화해야 합니다.

2) kid 미스가 나면 “즉시 JWKS 재조회”하도록 만들기

가장 효과적인 패턴은 이것입니다.

  • 평소에는 캐시를 사용
  • 검증 실패 원인이 kid not found라면 JWKS를 즉시 다시 fetch
  • 재조회 후에도 없으면 진짜 401

OpenResty 기반으로는 대략 이런 형태의 흐름이 가능합니다(개념 예시).

# 개념 예시: OpenResty(lua)에서 JWKS 캐시를 공유 딕셔너리에 저장
lua_shared_dict jwks_cache 10m;

server {
  location / {
    access_by_lua_block {
      local jwt = require "resty.jwt"
      local cjson = require "cjson.safe"
      local http = require "resty.http"

      local jwks_url = os.getenv("JWKS_URL")
      local token = ngx.var.http_authorization
      if not token then
        return ngx.exit(401)
      end

      token = token:gsub("^Bearer%s+", "")

      local function fetch_jwks()
        local httpc = http.new()
        httpc:set_timeout(2000)
        local res, err = httpc:request_uri(jwks_url, { method = "GET", ssl_verify = true })
        if not res then
          return nil, err
        end
        if res.status ~= 200 then
          return nil, "jwks status " .. res.status
        end
        return res.body, nil
      end

      local cache = ngx.shared.jwks_cache
      local jwks_body = cache:get("jwks")

      if not jwks_body then
        jwks_body = assert(fetch_jwks())
        cache:set("jwks", jwks_body, 300)  -- TTL 5분 예시
      end

      -- 1차 검증
      local jwt_obj = jwt:verify_jwks(jwks_body, token)

      -- kid not found면 JWKS 즉시 재조회 후 2차 검증
      if not jwt_obj.verified and jwt_obj.reason and jwt_obj.reason:find("kid") then
        local new_body = fetch_jwks()
        if new_body then
          cache:set("jwks", new_body, 300)
          jwt_obj = jwt:verify_jwks(new_body, token)
        end
      end

      if not jwt_obj.verified then
        ngx.log(ngx.WARN, "jwt verify failed: ", jwt_obj.reason or "unknown")
        return ngx.exit(401)
      end
    }

    proxy_pass http://upstream;
  }
}

주의할 점은, 위 코드는 “아이디어”이며 운영 배포 전에는 반드시 다음을 추가해야 합니다.

  • JWKS fetch 실패 시 동작 정책(기존 캐시 사용 여부)
  • 동시성 제어(여러 워커가 동시에 JWKS 갱신하지 않도록 락)
  • 로깅 시 토큰/개인정보 비노출

3) 멀티 인스턴스에서 캐시 일관성을 확보하기

Nginx 인스턴스가 여러 대면, 각자 로컬 캐시를 들고 있을 때 롤오버 순간에 401이 분산 발생합니다.

해결 방향은 보통 셋 중 하나입니다.

  • 중앙 검증 서비스로 위임(auth_request), 검증 서비스가 JWKS를 단일 캐시로 관리
  • Redis 같은 외부 캐시로 JWKS 공유
  • 롤오버/배포 이벤트 때 Nginx 전체 리로드로 캐시 동기화

4) 실패를 401로만 보지 말고 “원인별로 계측”하기

401 하나로 뭉개면 운영에서 끝없이 헤맵니다. 최소한 아래처럼 구분해 카운팅하는 것을 권장합니다.

  • jwt_missing (Authorization 헤더 없음)
  • jwt_malformed
  • jwt_kid_not_found
  • jwks_fetch_failed
  • jwt_signature_invalid
  • jwt_claim_invalid (exp/nbf/aud/iss)

이렇게 분해하면 “kid not found가 늘었다”는 사실만으로도 키 롤오버/캐시 문제를 즉시 의심할 수 있습니다.

자주 놓치는 함정 6가지

1) jwks_uri가 Issuer와 불일치

토큰의 isshttps://issuer-a인데, Nginx는 issuer-b의 JWKS를 보고 있으면 kid가 절대 맞을 수 없습니다. 환경변수/ConfigMap이 섞이는 경우가 흔합니다.

2) IdP가 JWKS를 갱신했는데 CDN/프록시가 오래 캐시

jwks_uri가 CDN 뒤에 있거나 사내 프록시가 캐시하면, Nginx가 아무리 자주 가져와도 같은 응답을 받을 수 있습니다. 이때는 Cache-Control 헤더와 프록시 정책을 확인해야 합니다.

3) TLS 중간 인증서/루트 CA 문제

컨테이너 이미지가 슬림한 경우 CA 번들이 없어서 ssl_verify가 실패합니다. 운영에서는 검증을 끄는 방식으로 땜질하기 쉬운데, JWKS를 위변조당하면 바로 인증 우회로 이어질 수 있어 위험합니다.

4) 시간 오차

exp 직전 토큰이 대량으로 401이 나는 것처럼 보이면, 실제로는 서버 시간이 앞서 있는 경우가 있습니다. 클라우드 스토리지/SAS처럼 시간 민감한 인증도 동일한 패턴이 나는데, 시간 오차 관점은 Azure Blob 403 AuthorizationFailure - SAS·RBAC·시간오차에서 다룬 내용과 결이 같습니다.

5) 알고리즘 혼동(alg)

보안상 alg를 허용 목록으로 고정하지 않으면 위험합니다. 반대로 IdP가 RS256에서 ES256으로 바뀌었는데 검증기가 고정되어 있으면 401이 납니다. 운영 변경 시점과 맞물리는지 확인하세요.

6) 로그에 토큰을 그대로 남김

401을 잡겠다고 Authorization 헤더를 통째로 로그에 남기는 실수가 많습니다. 토큰은 사실상 세션이므로 유출 시 계정 탈취로 이어집니다. kid, iss, aud, exp처럼 필요한 최소 정보만 남기세요.

운영에서 바로 적용할 체크리스트

빠른 분기(5분 컷)

  1. 401 토큰의 kid 추출
  2. jwks_uri에서 해당 kid 존재 여부 확인
  3. Nginx 실행 환경에서 jwks_uricurl 가능 여부 확인

이 3가지만 해도 원인의 80%가 갈립니다.

근본 개선(재발 방지)

  • kid not found 발생 시 JWKS 강제 재조회 로직
  • JWKS 캐시 TTL을 롤오버 정책과 정렬
  • 멀티 인스턴스 캐시 일관성(중앙 검증 또는 외부 캐시)
  • 시간 동기화(NTP)와 exp/nbf 허용 오차(leeway) 정책
  • 원인별 401 계측 및 알림(특히 jwks_fetch_failed, jwt_kid_not_found)

마무리

Nginx에서 JWT 검증 401이 갑자기 늘면, 애플리케이션 로직보다 먼저 jwks_uri 캐시와 kid 롤오버 타이밍을 의심하는 게 정답인 경우가 많습니다. 토큰 헤더의 kid를 기준으로 “JWKS에 키가 있나/없나”, “Nginx가 JWKS를 가져올 수 있나/없나”를 분리하면, 재현 가능한 방식으로 원인을 좁힐 수 있습니다.

운영에서 중요한 건 단순히 401을 없애는 게 아니라, 키 회전과 네트워크 실패 같은 정상적인 이벤트에도 인증 경로가 예측 가능하게 동작하도록 캐시 전략과 실패 정책을 설계하는 것입니다.