Published on

JWT 검증에서 jwks_uri 404·kid 불일치 해결

Authors

서버에서 JWT를 검증하다 보면 두 가지가 특히 사람을 괴롭힙니다. jwks_uri로 JWKS(JSON Web Key Set)를 가져오려는데 404가 나거나, 토큰 헤더의 kid가 JWKS에 없어서 kid not found/No matching key로 검증이 실패하는 상황입니다. 겉보기엔 단순 네트워크/설정 문제 같지만, 실제로는 Issuer(발급자) 불일치, 잘못된 디스커버리 엔드포인트, 키 회전(rotation) 타이밍, 캐시/프록시, 알고리즘 혼선(RS256 vs HS256) 등 여러 층위가 얽혀 발생합니다.

이 글에서는 404와 kid 불일치를 각각 원인별로 분해하고, 재현/진단 커맨드, Node.js/Java/Spring Security에서의 안전한 검증 구현, **운영에서 재발 방지(캐시·회전·관측성)**까지 한 번에 정리합니다.

> 참고: 쿠버네티스/EKS 환경에서만 특정 Pod에서 JWKS 호출이 실패한다면 네트워크 레벨도 같이 의심해야 합니다. HTTPS만 실패하는 케이스는 EKS Pod DNS는 되는데 HTTPS만 실패할 때 점검도 함께 보세요.

JWT 검증 흐름을 먼저 고정하기

JWT 서명 검증은 대개 다음 순서로 이뤄집니다.

  1. 토큰 헤더에서 alg, kid를 읽음
  2. 토큰의 iss(issuer)를 기반으로 OIDC Discovery(/.well-known/openid-configuration)에서 jwks_uri를 찾거나, 미리 설정된 jwks_uri로 JWKS를 가져옴
  3. JWKS에서 kid가 일치하는 공개키(JWK)를 선택
  4. 해당 공개키로 서명 검증(예: RS256)
  5. 클레임 검증: iss, aud, exp, nbf, iat, (필요 시) azp, scope

따라서 404와 kid 불일치는 대부분 2~3단계에서 발생합니다. 문제를 빨리 풀려면 “지금 내 서버가 어떤 iss를 믿고, 어떤 jwks_uri를 때리고 있으며, 토큰의 kid가 무엇인지”를 로그로 확정해야 합니다.

증상 1) jwks_uri 404: 원인별 체크리스트

jwks_uri 404는 “서버가 잘못된 URL을 호출하고 있다”가 80%입니다. 나머지는 네트워크/프록시/라우팅 문제입니다.

1) Issuer(iss)와 Discovery URL을 혼동한 경우

가장 흔한 실수는 iss를 그대로 jwks_uri로 간주하거나, 테넌트/리전/풀 ID가 빠진 URL을 쓰는 것입니다.

  • 올바른 패턴
    • Discovery: https://<issuer>/.well-known/openid-configuration
    • JWKS: discovery 문서의 jwks_uri

다음처럼 discovery를 먼저 조회해서 jwks_uri를 확정하세요.

ISSUER="https://example-idp.com/oauth2/default"

curl -fsSL "$ISSUER/.well-known/openid-configuration" | jq -r '.jwks_uri'

여기서 404면 ISSUER 자체가 틀렸거나, 해당 IdP가 OIDC discovery를 제공하지 않거나(커스텀), 경로가 다릅니다.

2) 리버스 프록시/게이트웨이에서 /.well-known 또는 /keys가 차단

사내 게이트웨이나 API Gateway가 /.well-known/* 같은 경로를 보안상 차단해 404로 응답하는 경우가 있습니다(실제론 403이어야 하지만 404로 숨기는 정책도 흔함).

  • 프록시/Nginx 설정에서 해당 경로가 upstream으로 전달되는지 확인
  • WAF 룰에서 well-known이 차단되지 않는지 확인
  • 외부에서 직접 IdP 도메인으로 호출했을 때는 200인데, 내부 경유 시만 404라면 라우팅 문제

3) 멀티 리전/멀티 환경에서 잘못된 도메인 사용

devdev-idp.example.com, prodidp.example.com인데 설정이 섞이면 404가 납니다. 특히 IaC로 배포할 때 환경변수/시크릿이 섞이는 사고가 빈번합니다.

  • ISSUERJWKS_URI환경별로 명시적으로 분리
  • 서비스 시작 시 discovery 결과를 로깅(민감정보 제외)

4) 네트워크 레벨에서만 실패(특정 Pod/서브넷)

EKS/사내망에서만 404가 난다면 실제로는 404가 아니라 프록시가 변조한 응답일 수 있습니다.

  • 동일 URL을 Pod 내부에서 curl -v로 확인
  • egress가 NAT/WAF를 거치며 특정 경로를 차단할 수 있음

네트워크 이슈 전반 점검은 EKS Pod는 뜨는데 트래픽 0 - NetPol·SG·CNI 10분 진단도 참고하면 좋습니다.

증상 2) kid 불일치: 왜 JWKS에 키가 없을까?

kid 불일치는 보통 아래 중 하나입니다.

  1. 토큰이 다른 Issuer에서 발급됨 (가장 흔함)
  2. 키 회전 직후인데 서버가 JWKS를 캐시하고 있어 최신 키를 못 봄
  3. JWKS가 여러 개(여러 authorization server/tenant)인데 잘못된 JWKS를 조회
  4. alg 혼선: HS256 토큰인데 RS256 공개키로 검증하려 함(또는 반대)
  5. 토큰이 변조되었거나, 헤더의 kid가 공격적으로 조작됨

1) 토큰의 iss, kid를 먼저 눈으로 확인

JWT를 디코드해서(서명 검증 없이) 헤더/페이로드를 확인합니다.

TOKEN="eyJ..."

# 헤더
python - <<'PY'
import base64, json, os

t=os.environ['TOKEN'].split('.')
header=json.loads(base64.urlsafe_b64decode(t[0]+'=='))
print(json.dumps(header, indent=2))
PY

# 페이로드
python - <<'PY'
import base64, json, os

t=os.environ['TOKEN'].split('.')
payload=json.loads(base64.urlsafe_b64decode(t[1]+'=='))
print(json.dumps(payload, indent=2))
PY

여기서 확인할 핵심:

  • header: kid, alg
  • payload: iss, aud, exp

iss가 기대한 값과 다르면, 그 순간부터는 “키가 없다”가 아니라 “다른 발급자 토큰을 들고 검증하고 있다”가 정답입니다.

2) JWKS에 정말 kid가 없는지 확인

jwks_uri에서 키 목록을 받아 kid를 찾습니다.

JWKS_URI="https://example-idp.com/oauth2/default/v1/keys"

curl -fsSL "$JWKS_URI" | jq -r '.keys[].kid'
  • 목록에 없으면 키 회전/캐시/잘못된 JWKS 중 하나
  • 목록에 있는데도 매칭이 안 되면, 라이브러리가 kid 선택을 잘못하고 있거나(드묾), JWK 파싱 문제, 혹은 x5c/n/e 필드가 기대와 다른 형식일 수 있습니다.

3) 키 회전(rotation) + 캐시로 인한 시간차

IdP는 키를 회전하면서 새 kid를 발급하고, 일정 기간 이전 키도 함께 JWKS에 유지합니다. 하지만 다음과 같은 조합이면 실패가 납니다.

  • IdP는 이미 새 키로 서명한 토큰을 발급
  • 우리 서버는 JWKS를 오래 캐시(예: 24시간)
  • 결과: 새 kid를 모름 → 검증 실패

해결책:

  • JWKS 캐시 TTL을 짧게(예: 5~15분) 잡고, 실패 시 강제 리프레시
  • Cache-Control/Expires 헤더를 존중하되, 상한/하한을 둠
  • 다중 인스턴스면 캐시를 공유(redis)하거나, 각 인스턴스가 독립적으로도 빠르게 갱신하게 설계

4) 여러 Authorization Server/테넌트가 섞이는 구성

Okta/Auth0/Keycloak/Cognito 등에서 “테넌트/realm/authorization server”마다 키셋이 다릅니다.

  • iss → discovery → jwks_uri 경로가 항상 1:1로 매핑되어야 함
  • aud만 보고 검증하면 안 되고, 반드시 iss를 기반으로 JWKS를 선택

Node.js에서 안전한 JWKS 기반 검증 예제

운영에서 가장 안전한 패턴은:

  • iss allowlist
  • discovery 또는 고정 jwks_uri
  • JWKS 캐시 + 레이트리밋
  • kid 미스 시 1회 강제 리프레시

아래는 jose 기반 예시입니다.

import { jwtVerify, createRemoteJWKSet } from 'jose';

const ISSUER = process.env.OIDC_ISSUER; // 예: https://example-idp.com/oauth2/default
const AUDIENCE = process.env.OIDC_AUDIENCE; // 예: api://my-service

// jose는 내부적으로 캐시/재시도를 지원하지만,
// 운영에서는 timeout과 실패 로깅을 반드시 추가하세요.
const jwks = createRemoteJWKSet(new URL(`${ISSUER}/v1/keys`), {
  cooldownDuration: 30_000, // kid miss 후 재조회 쿨다운
  timeoutDuration: 5_000,
});

export async function verifyAccessToken(token) {
  const { payload, protectedHeader } = await jwtVerify(token, jwks, {
    issuer: ISSUER,
    audience: AUDIENCE,
  });

  // 필요 시 추가 클레임 검증
  // 예: scope/roles
  return {
    header: protectedHeader,
    claims: payload,
  };
}

포인트:

  • issuer, audience를 반드시 고정
  • ISSUER가 다르면 JWKS도 달라지므로, 멀티 issuer면 issuer별 JWKS 인스턴스를 분리
  • timeoutDuration을 두지 않으면 요청이 쌓여 장애로 번질 수 있음

Spring Security(Resource Server)에서 자주 하는 실수와 해결

Spring Security는 설정이 단순한 대신, 잘못 잡히면 “왜 저 URL로 JWKS를 호출하지?” 같은 혼란이 생깁니다.

1) 권장: issuer-uri 기반 자동 구성

가능하면 jwk-set-uri를 직접 박지 말고 issuer-uri로 discovery를 타게 하세요.

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://example-idp.com/oauth2/default
          audiences: api://my-service
  • issuer-uri가 틀리면 discovery 자체가 404
  • issuer-uri는 토큰의 iss정확히 일치해야 함

2) jwk-set-uri를 직접 지정해야 하는 경우

사내 IdP가 discovery를 제공하지 않거나, 프록시 경로 때문에 discovery가 막히면 jwk-set-uri를 직접 지정할 수 있습니다.

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: https://example-idp.com/oauth2/default/v1/keys

이때도 iss 검증을 포기하지 마세요. 커스텀 Validator로 iss allowlist를 적용하는 것이 안전합니다.

운영에서 재발을 줄이는 관측/방어 전략

1) 로그에 남겨야 할 최소 정보

민감정보를 제외하고 아래는 남기는 게 좋습니다.

  • 토큰 헤더의 kid, alg
  • 토큰 페이로드의 iss, aud(가능하면)
  • 최종적으로 호출한 jwks_uri
  • JWKS 응답 상태코드(200/404) 및 캐시 히트 여부

이 정보만 있으면 “잘못된 issuer”인지 “키 회전/캐시”인지 5분 내로 갈립니다.

2) kid miss 시 동작 정책

  • 1회에 한해 JWKS 강제 리프레시 후 재검증
  • 그래도 실패면 401
  • 같은 kid로 반복 실패하면 서킷브레이커/레이트리밋(로그 폭발 방지)

레이트리밋/재시도 설계 감각은 인증/키 조회에도 그대로 적용됩니다. 재시도가 과하면 IdP를 때려 장애를 키웁니다. 비슷한 관점의 글로 Anthropic Claude 429 레이트리밋 재시도 설계법도 참고할 만합니다.

3) 캐시 헤더를 신뢰하되, 상한을 둔다

IdP가 Cache-Control: max-age=3600을 주더라도, 키 회전이 잦은 환경이면 상한을 5~15분으로 제한하는 게 안전할 때가 많습니다. 반대로 너무 짧으면 IdP에 부하가 가므로, 트래픽 규모에 맞춰 조정하세요.

빠른 결론: 404와 kid 불일치를 가장 빨리 푸는 순서

  1. 토큰에서 iss, kid, alg를 디코드해서 확정
  2. iss/.well-known/openid-configuration이 200인지 확인하고 jwks_uri를 얻기
  3. jwks_uri가 404면 프록시/라우팅/환경변수(issuer)부터 의심
  4. jwks_uri가 200인데 kid가 없으면 키 회전+캐시 또는 다른 issuer 토큰 혼입
  5. kid miss 시 JWKS 강제 리프레시 1회 정책을 넣고, TTL/관측성을 조정

이 과정을 자동화해두면, JWT 검증 장애의 대부분은 “원인 파악”이 아니라 “설정 한 줄 수정”으로 끝납니다.