- Published on
Nginx HTTPS에서 JWT 검증 실패? kid·JWKS 캐시 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버를 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로 바꾸면서 다음 변화가 동반되는 경우가 많습니다.
- 프록시 체인 증가:
LB -> Nginx -> upstream형태로 바뀌며, 외부로 나가는 egress 정책/방화벽/프록시가 달라짐 - SNI/CA 검증 강화: JWKS를
https://로 가져오는데, 컨테이너/서버의 CA 번들이 없거나 오래되어 TLS 핸드셰이크 실패 - 리다이렉트/호스트 불일치: IdP가
http에서https로 리다이렉트 하거나,Host/X-Forwarded-Proto에 따라 다른 issuer/JWKS URL을 내줌 - 캐시가 “성공/실패”를 모두 기억: JWKS fetch가 한번 실패하면 그 실패 상태가 캐시되어 일정 시간 계속 실패
- 키 롤오버 타이밍: 토큰은 새
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을 받습니다.
문제는 여기서 발생합니다.
- 토큰의
kid가abc123 - Nginx 캐시에 있는 JWKS에는
kid가old999만 있음 - 검증 실패
따라서 캐시를 “얼마나 오래” 들고 갈지, 그리고 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 Host및X-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 강제 갱신” + “성공 캐시만 유지”
가장 실전적인 전략은 다음 두 가지를 결합하는 것입니다.
- JWKS를
lua_shared_dict에 캐시한다(워커 간 공유) - 토큰의
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가지를 적용하는 것이 가장 효과적입니다.
- JWKS를 워커 간 공유 캐시에 저장(
lua_shared_dict등) kid미스매치 시 JWKS를 즉시 재조회하고 재검증- JWKS fetch 실패 시에도 마지막 성공 캐시(stale)를 활용해 가용성 유지
이 패턴을 잡아두면, 키 롤오버나 일시적 네트워크 이슈가 있어도 인증 장애가 “전면 다운”으로 번지는 일을 크게 줄일 수 있습니다.