- Published on
JWT 검증에서 jwks_uri 404·kid 불일치 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 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 서명 검증은 대개 다음 순서로 이뤄집니다.
- 토큰 헤더에서
alg,kid를 읽음 - 토큰의
iss(issuer)를 기반으로 OIDC Discovery(/.well-known/openid-configuration)에서jwks_uri를 찾거나, 미리 설정된jwks_uri로 JWKS를 가져옴 - JWKS에서
kid가 일치하는 공개키(JWK)를 선택 - 해당 공개키로 서명 검증(예: RS256)
- 클레임 검증:
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:
다음처럼 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) 멀티 리전/멀티 환경에서 잘못된 도메인 사용
dev는 dev-idp.example.com, prod는 idp.example.com인데 설정이 섞이면 404가 납니다. 특히 IaC로 배포할 때 환경변수/시크릿이 섞이는 사고가 빈번합니다.
ISSUER와JWKS_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 불일치는 보통 아래 중 하나입니다.
- 토큰이 다른 Issuer에서 발급됨 (가장 흔함)
- 키 회전 직후인데 서버가 JWKS를 캐시하고 있어 최신 키를 못 봄
- JWKS가 여러 개(여러 authorization server/tenant)인데 잘못된 JWKS를 조회
- alg 혼선: HS256 토큰인데 RS256 공개키로 검증하려 함(또는 반대)
- 토큰이 변조되었거나, 헤더의
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 기반 검증 예제
운영에서 가장 안전한 패턴은:
issallowlist- 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 불일치를 가장 빨리 푸는 순서
- 토큰에서
iss,kid,alg를 디코드해서 확정 iss/.well-known/openid-configuration이 200인지 확인하고jwks_uri를 얻기jwks_uri가 404면 프록시/라우팅/환경변수(issuer)부터 의심jwks_uri가 200인데kid가 없으면 키 회전+캐시 또는 다른 issuer 토큰 혼입- kid miss 시 JWKS 강제 리프레시 1회 정책을 넣고, TTL/관측성을 조정
이 과정을 자동화해두면, JWT 검증 장애의 대부분은 “원인 파악”이 아니라 “설정 한 줄 수정”으로 끝납니다.