Published on

Nginx HTTPS에서 JWT 검증 실패? kid·JWKS 캐시 해결

Authors

서버를 HTTP에서 HTTPS로 전환한 뒤, 애플리케이션(백엔드)에서는 정상인데 Nginx 레이어에서만 JWT 검증이 간헐적으로 실패하는 케이스가 종종 있습니다. 로그에는 kid를 찾지 못한다거나, JWKS(JSON Web Key Set)를 가져오지 못했다는 메시지가 섞여 나오고요.

이 글은 다음 같은 증상을 겪는 상황을 전제로 합니다.

  • HTTPS 적용 후 401 Unauthorized가 간헐적으로 증가
  • 에러 메시지에 kid가 등장(예: no matching JWK for kid)하거나, JWKS fetch 실패 로그가 보임
  • IdP(Keycloak, Auth0, Cognito 등)에서 키 롤오버가 있었거나, 주기적으로 발생하는 듯함
  • Nginx(OpenResty 포함)에서 auth_request 또는 Lua로 JWT를 검증 중

핵심은 kid는 토큰 헤더가 가리키는 공개키 식별자이고, 이 공개키는 보통 JWKS 엔드포인트에서 내려받아 캐시한다는 점입니다. HTTPS 전환은 단순히 TLS만 바꾸는 게 아니라, 프록시 경로/헤더/타임아웃/캐싱 레이어를 바꾸면서 JWKS 조회 실패나 잘못된 캐시를 촉발할 수 있습니다.

관련해서 OAuth2/OIDC 흐름 자체의 오류 패턴을 함께 점검하고 싶다면, OAuth2 PKCE에서 invalid_grant 나는 7가지도 같이 보면 원인 분리가 빨라집니다.

왜 HTTPS 전환 후에만 터질까

HTTPS로 바꾸면서 다음 변화가 동반되는 경우가 많습니다.

  1. 프록시 체인 증가: LB -> Nginx -> upstream 형태로 바뀌며, 외부로 나가는 egress 정책/방화벽/프록시가 달라짐
  2. SNI/CA 검증 강화: JWKS를 https://로 가져오는데, 컨테이너/서버의 CA 번들이 없거나 오래되어 TLS 핸드셰이크 실패
  3. 리다이렉트/호스트 불일치: IdP가 http에서 https로 리다이렉트 하거나, Host/X-Forwarded-Proto에 따라 다른 issuer/JWKS URL을 내줌
  4. 캐시가 “성공/실패”를 모두 기억: JWKS fetch가 한번 실패하면 그 실패 상태가 캐시되어 일정 시간 계속 실패
  5. 키 롤오버 타이밍: 토큰은 새 kid로 발급되었는데, Nginx가 들고 있는 JWKS 캐시는 아직 이전 키만 포함

특히 4, 5번이 합쳐지면 “간헐적이지만 특정 시간대에 폭발”하는 패턴이 잘 나옵니다.

JWT의 kid와 JWKS 캐시의 관계

JWT는 보통 header.payload.signature 형태이고, 헤더에는 아래처럼 kid가 들어갑니다.

  • kid: 어떤 공개키로 서명을 검증해야 하는지 알려주는 식별자
  • alg: 서명 알고리즘(예: RS256)

IdP는 공개키를 JWKS로 제공합니다. 예를 들어 /.well-known/openid-configuration에서 jwks_uri를 얻고, 그 URL로 가면 여러 개의 키(여러 kid)가 들어있는 JSON을 받습니다.

문제는 여기서 발생합니다.

  • 토큰의 kidabc123
  • Nginx 캐시에 있는 JWKS에는 kidold999만 있음
  • 검증 실패

따라서 캐시를 “얼마나 오래” 들고 갈지, 그리고 kid 미스매치가 나왔을 때 JWKS를 즉시 갱신할지가 관건입니다.

흔한 원인 7가지 (체크리스트)

1) JWKS 응답이 CDN/LB에서 잘못 캐시됨

JWKS는 일반적으로 캐시 가능하지만, 키 롤오버 직후에는 “이전 JWKS”가 남아있으면 안 됩니다. Cache-Control을 무시하거나, 프록시가 과도하게 캐시하는 경우가 있습니다.

  • 대응: JWKS 조회 경로는 별도 캐시 정책을 적용하거나, kid 미스매치 시 강제 갱신 로직을 넣습니다.

2) Nginx의 DNS 캐시/리졸버 문제

Nginx는 업스트림 이름 해석을 한 번 해놓고 오래 들고 가는 패턴이 있습니다. IdP가 장애 조치로 IP가 바뀌면 JWKS fetch가 실패할 수 있습니다.

  • 대응: resolver 설정과 valid를 명시하고, Lua에서 ssl_server_name(SNI)도 신경 씁니다.

3) CA 번들/인증서 검증 실패

컨테이너 이미지가 슬림하면 CA 번들이 없어서 https:// 호출이 실패할 수 있습니다.

  • 대응: OS CA 패키지 설치(예: ca-certificates) 또는 Lua/라이브러리에서 CA 경로 지정

4) X-Forwarded-Proto/Host 불일치로 issuer가 달라짐

IdP가 issuer를 동적으로 만들거나 프록시 헤더에 민감한 경우, 애플리케이션은 정상인데 Nginx에서만 다른 엔드포인트를 보게 될 수 있습니다.

  • 대응: proxy_set_header HostX-Forwarded-Proto를 표준화

Keycloak을 쓰는 경우에는 프록시/리다이렉트 설정이 겹치면 증상이 더 복잡해집니다. 비슷한 맥락으로 Keycloak OAuth2 로그인 무한 리다이렉트 8가지 원인도 참고할 만합니다.

5) 키 롤오버(회전) 직후 캐시 갱신 전략 부재

IdP는 새 키를 추가한 뒤 일정 기간 이전 키를 함께 제공하다가 제거합니다. 이 “겹치는 기간”을 놓치면 장애가 납니다.

  • 대응: JWKS TTL을 너무 길게 잡지 말고, kid 미스매치 시 즉시 재조회

6) JWKS fetch 타임아웃/재시도 부족

TLS 핸드셰이크/네트워크 지연으로 JWKS fetch가 느려지면, 검증 요청이 몰릴 때 타임아웃이 나고 실패를 캐싱할 수도 있습니다.

  • 대응: 합리적 타임아웃, 재시도, stale 캐시 사용(성공했던 마지막 JWKS를 계속 사용)

7) Nginx 워커별 캐시 분산

lua_shared_dict를 쓰지 않고 워커 로컬에 캐시하면, 워커마다 다른 JWKS를 들고 있어 “간헐적”이 됩니다.

  • 대응: 반드시 lua_shared_dict 같은 공유 캐시 사용

해결 전략: “kid 미스매치 시 JWKS 강제 갱신” + “성공 캐시만 유지”

가장 실전적인 전략은 다음 두 가지를 결합하는 것입니다.

  1. JWKS를 lua_shared_dict에 캐시한다(워커 간 공유)
  2. 토큰의 kid가 JWKS에 없으면, JWKS를 즉시 다시 받아온 뒤 재검증한다

추가로 운영 안정성을 위해 아래도 권장합니다.

  • JWKS fetch 실패 시에도 **이전 성공 캐시(stale)**가 있으면 그것으로 검증을 계속 시도
  • JWKS fetch 실패를 “실패 캐시”로 오래 저장하지 않기

예제: OpenResty(Lua)로 JWKS 캐시 + kid 미스매치 갱신

아래는 개념을 보여주는 예시입니다. 실제 운영에서는 검증 라이브러리(예: lua-resty-jwt, lua-resty-openidc)와 에러 처리, 로깅을 더 촘촘히 해야 합니다.

1) Nginx 설정

nginx.conf 또는 사이트 설정에 공유 딕셔너리와 리졸버를 둡니다.

lua_shared_dict jwks_cache 10m;

resolver 1.1.1.1 8.8.8.8 valid=60s ipv6=off;
resolver_timeout 5s;

server {
  listen 443 ssl;

  # upstream으로 넘길 때 프록시 헤더 표준화
  location / {
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    access_by_lua_file /etc/nginx/lua/jwt_verify.lua;

    proxy_pass http://app_upstream;
  }
}

여기서 resolver는 JWKS를 도메인으로 가져올 때 중요합니다. 특히 컨테이너 환경에서 /etc/resolv.conf가 동적으로 바뀌는 경우가 많아 명시하는 편이 안전합니다.

2) Lua 검증 로직(핵심 아이디어)

  • 토큰에서 kid를 파싱
  • 캐시된 JWKS에 kid가 없으면 JWKS를 다시 fetch
  • fetch 실패 시에는 마지막 성공 캐시를 사용
-- /etc/nginx/lua/jwt_verify.lua
local cjson = require "cjson.safe"
local http = require "resty.http"

local cache = ngx.shared.jwks_cache

local JWKS_URL = "https://idp.example.com/realms/my/protocol/openid-connect/certs"
local CACHE_KEY = "jwks_json"
local CACHE_TTL = 300  -- 5분: 너무 길게 잡지 않는 편이 안전

local function fetch_jwks()
  local httpc = http.new()
  httpc:set_timeouts(2000, 2000, 2000)

  local res, err = httpc:request_uri(JWKS_URL, {
    method = "GET",
    ssl_verify = true,
    headers = { ["Accept"] = "application/json" },
  })

  if not res then
    return nil, "jwks request failed: " .. (err or "unknown")
  end
  if res.status ~= 200 then
    return nil, "jwks status not 200: " .. tostring(res.status)
  end

  local body = res.body
  local obj = cjson.decode(body)
  if not obj or not obj.keys then
    return nil, "jwks json invalid"
  end

  cache:set(CACHE_KEY, body, CACHE_TTL)
  return body, nil
end

local function get_cached_jwks()
  return cache:get(CACHE_KEY)
end

local function get_bearer_token()
  local h = ngx.var.http_authorization
  if not h then return nil end
  local m = ngx.re.match(h, [[^Bearer\s+(.+)$]], "jo")
  return m and m[1] or nil
end

local function jwt_header_kid(token)
  -- JWT header는 첫 번째 세그먼트(base64url)
  local header_b64 = token:match("^([^.]+)\.")
  if not header_b64 then return nil end

  -- base64url 디코딩
  header_b64 = header_b64:gsub("-", "+"):gsub("_", "/")
  local pad = #header_b64 % 4
  if pad == 2 then header_b64 = header_b64 .. "==" end
  if pad == 3 then header_b64 = header_b64 .. "=" end

  local header_json = ngx.decode_base64(header_b64)
  if not header_json then return nil end

  local header = cjson.decode(header_json)
  return header and header.kid or nil
end

local function jwks_has_kid(jwks_json, kid)
  local obj = cjson.decode(jwks_json)
  if not obj or not obj.keys then return false end
  for _, k in ipairs(obj.keys) do
    if k.kid == kid then
      return true
    end
  end
  return false
end

-- 실제 서명 검증은 라이브러리를 쓰는 것을 권장
-- 여기서는 "kid 미스매치 시 JWKS 갱신" 흐름만 보여줌
local token = get_bearer_token()
if not token then
  ngx.status = 401
  ngx.say("missing bearer token")
  return ngx.exit(401)
end

local kid = jwt_header_kid(token)
if not kid then
  ngx.status = 401
  ngx.say("missing kid")
  return ngx.exit(401)
end

local jwks_json = get_cached_jwks()
if not jwks_json then
  local fresh, err = fetch_jwks()
  if not fresh then
    ngx.log(ngx.ERR, "jwks initial fetch failed: ", err)
    ngx.status = 503
    ngx.say("jwks unavailable")
    return ngx.exit(503)
  end
  jwks_json = fresh
end

if not jwks_has_kid(jwks_json, kid) then
  -- 핵심: kid가 없으면 즉시 갱신
  local fresh, err = fetch_jwks()
  if fresh and jwks_has_kid(fresh, kid) then
    jwks_json = fresh
  else
    -- 갱신 실패 시 stale 캐시로 계속 시도(운영 안정성)
    ngx.log(ngx.WARN, "jwks refresh failed or kid still missing. kid=", kid, ", err=", err)
  end
end

-- TODO: 여기서 jwks_json을 사용해 JWT 서명/클레임 검증 수행
-- 검증 실패 시 401, 성공 시 통과

위 코드는 “완전한 JWT 검증기”가 아니라, HTTPS 전환 이후 흔히 터지는 kid/JWKS 캐시 문제를 해결하는 패턴을 보여주기 위한 것입니다.

운영에서는 다음을 추가하세요.

  • iss, aud, exp, nbf 검증
  • 시계 오차 허용(예: leeway 몇 초)
  • 검증 실패 로깅에 토큰 원문을 남기지 않기(보안)

Nginx auth_request를 쓰는 경우의 포인트

auth_request로 별도 인증 서브리퀘스트를 날리는 구조라면, JWKS fetch는 인증 서비스(또는 Lua)에서 수행됩니다. 이때도 원리는 같습니다.

  • 인증 서브리퀘스트가 매 요청마다 JWKS를 가져오지 않도록 캐시
  • 키 롤오버 시 kid 미스매치가 나면 캐시 갱신
  • 실패 캐시를 오래 들고 가지 않기

추가로, 인증 서브리퀘스트는 트래픽이 높으면 병목이 되기 쉬우니, 캐시 히트율과 지연을 꼭 모니터링하세요.

디버깅: 무엇을 어떻게 확인할까

1) 토큰의 kid 확인

운영 환경에서 토큰을 직접 다루기 어렵다면, 샘플 토큰으로 헤더만 확인하세요(서명/페이로드는 노출 주의).

TOKEN='eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyJ9.xxx.yyy'

# header만 base64url 디코딩
python - <<'PY'
import base64, json, os
h = os.environ['TOKEN'].split('.')[0]
h += '=' * (-len(h) % 4)
h = h.replace('-', '+').replace('_', '/')
print(json.loads(base64.b64decode(h)))
PY

2) JWKS에 해당 kid가 있는지 확인

curl -sS 'https://idp.example.com/realms/my/protocol/openid-connect/certs' \
  | jq -r '.keys[].kid' \
  | sort

토큰의 kid가 목록에 없다면, 거의 확실하게 키 롤오버 직후 캐시 문제거나, 잘못된 realm/issuer의 JWKS를 보고 있는 문제입니다.

3) TLS/CA 문제 확인

Nginx(OpenResty)가 돌아가는 동일한 노드/컨테이너에서 JWKS URL로 TLS 연결이 되는지 확인합니다.

curl -v 'https://idp.example.com/realms/my/protocol/openid-connect/certs'

여기서 certificate verify failed가 보이면 CA 번들부터 해결해야 합니다.

4) 캐시 TTL과 갱신 시점 확인

  • 캐시 TTL이 너무 길면 롤오버에 취약
  • 너무 짧으면 JWKS 엔드포인트에 부하

권장 접근은 “짧은 TTL + kid 미스매치 시 즉시 갱신 + stale 허용”입니다.

운영 팁: 키 롤오버에 안전한 캐시 설계

  • TTL을 5~15분 정도로 시작하고, 트래픽/롤오버 정책에 맞춰 조정
  • IdP가 키를 제거하기 전에 충분한 겹침 기간을 두는지 확인
  • kid 미스매치가 일정 비율 이상 발생하면 알람(키 롤오버/캐시 장애의 신호)
  • 멀티 인스턴스 환경에서는 공유 캐시(예: Redis) 또는 인스턴스별 캐시라도 짧은 TTL + 강제 갱신

EKS 같은 환경에서 OIDC 설정 자체가 꼬인 경우도 종종 JWKS/issuer 문제로 이어집니다. 인프라 레벨 OIDC 이슈가 의심되면 EKS OIDC Provider 400 invalid_client 해결 가이드도 원인 분리에 도움이 됩니다.

결론

HTTPS 전환 이후 Nginx에서 JWT 검증이 실패하는 문제는, 대다수가 kid와 JWKS의 불일치 그리고 그 배경에 있는 JWKS 캐시/갱신 전략 부재로 설명됩니다.

정리하면 다음 3가지를 적용하는 것이 가장 효과적입니다.

  1. JWKS를 워커 간 공유 캐시에 저장(lua_shared_dict 등)
  2. kid 미스매치 시 JWKS를 즉시 재조회하고 재검증
  3. JWKS fetch 실패 시에도 마지막 성공 캐시(stale)를 활용해 가용성 유지

이 패턴을 잡아두면, 키 롤오버나 일시적 네트워크 이슈가 있어도 인증 장애가 “전면 다운”으로 번지는 일을 크게 줄일 수 있습니다.