- Published on
JWT 검증 실패? JWKS 키 롤오버·kid 오류 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 JWT 검증이 잘 되다가 어느 순간부터 invalid signature, kid not found, unable to find a signing key 같은 오류가 터지면, 대부분은 토큰 자체가 “나쁜” 게 아니라 검증 측이 참조하는 JWKS 키셋이 최신 상태가 아니거나 kid 매칭이 깨진 상황입니다. 특히 IdP(예: Auth0, Cognito, Keycloak, Azure AD 등)가 키 롤오버(key rotation) 를 수행하면, 캐시·배포 타이밍·멀티 인스턴스 환경에서 문제가 폭발적으로 드러납니다.
이 글에서는 JWT/JWKS의 기본 개념을 짧게 짚고, 키 롤오버와 kid 오류를 빠르게 진단하고 고치는 방법을 코드와 운영 팁 중심으로 정리합니다.
증상 패턴: 에러 메시지로 원인 범위를 좁히기
JWT 검증 실패는 원인이 다양하지만, 메시지를 보면 범위를 빠르게 줄일 수 있습니다.
kid not found/unable to find a signing key that matches kid- 토큰 헤더의
kid에 해당하는 공개키가 현재 JWKS에 없음 - 캐시가 오래됐거나, 잘못된 JWKS URL을 보고 있거나, IdP가 롤오버 직후旧키를 제거했거나
- 토큰 헤더의
invalid signature/signature verification failedkid는 찾았는데 서명이 맞지 않음- 잘못된 키를 선택했거나(동일
kid충돌/환경 혼선), 알고리즘/키 타입 불일치, 토큰 변조
alg관련 오류(예:unexpected alg,none not allowed)- 검증 라이브러리가 기대하는 알고리즘과 토큰 헤더
alg가 다름 - 보안상
alg는 반드시 allowlist로 제한해야 함
- 검증 라이브러리가 기대하는 알고리즘과 토큰 헤더
인증 플로우 자체에서 invalid_grant가 뜨는 경우는 JWT 검증과는 다른 레이어 문제인 경우가 많습니다. 토큰 발급 단계가 의심된다면 OAuth PKCE인데 invalid_grant 뜨는 9가지도 함께 확인하세요.
JWT/JWKS/kid 핵심만 정리
- JWT 헤더에는 보통
alg,kid,typ가 있습니다. kid는 “이 토큰을 서명한 키의 식별자”입니다.- 검증 서버는 IdP의 JWKS 엔드포인트에서 공개키 목록(JSON)을 받아오고,
kid가 일치하는 키로 서명을 검증합니다.
즉, 검증은 대략 아래 순서입니다.
- 토큰 헤더 파싱
kid확보- JWKS에서
kid매칭되는 JWK 선택 - JWK를 공개키로 변환
algallowlist 확인- 서명 검증 +
iss,aud,exp등 클레임 검증
여기서 23이 흔들리면 “키를 못 찾는다”, 36이 흔들리면 “서명이 안 맞는다”로 나타납니다.
1차 진단: 토큰 헤더의 kid부터 확인
운영 중 장애 상황에서 가장 먼저 할 일은 “토큰 헤더의 kid가 무엇인지”를 확인하는 것입니다.
아래는 Node.js에서 JWT 헤더만 안전하게 디코딩하는 예시입니다(서명 검증 아님).
function decodeJwtHeader(token) {
const [h] = token.split('.')
const json = Buffer.from(h, 'base64url').toString('utf8')
return JSON.parse(json)
}
const header = decodeJwtHeader(process.env.JWT)
console.log(header) // { alg: 'RS256', kid: 'abc123', typ: 'JWT' }
다음으로 JWKS에서 해당 kid가 존재하는지 확인합니다.
curl -s https://YOUR_IDP/.well-known/jwks.json | jq '.keys[].kid'
- 토큰의
kid가 JWKS에 없다면: 캐시/롤오버/JWKS URL 문제 가능성이 높습니다. - 토큰의
kid가 JWKS에 있는데도 실패한다면: 키 선택 로직, 알고리즘 검증, 환경 혼선 등을 봐야 합니다.
가장 흔한 원인 1: JWKS 캐시가 키 롤오버를 따라가지 못함
왜 캐시가 문제를 만들까
검증 서버는 매 요청마다 JWKS를 받아오면 느리고, IdP에도 부담이 큽니다. 그래서 대부분의 구현은 JWKS를 캐싱합니다.
문제는 키 롤오버 순간에 발생합니다.
- IdP가 새 키를 추가하고 새
kid로 토큰을 발급 - 검증 서버는 아직旧 JWKS를 캐시 중
- 새 토큰의
kid를 찾지 못해 검증 실패
해결 패턴: kid not found일 때 JWKS를 강제 갱신
운영에서 가장 효과적인 패턴은 다음입니다.
- 평소에는 JWKS를 TTL로 캐시
- 검증 중
kid매칭 실패가 나면 즉시 JWKS를 재조회 후 재시도
아래는 간단한 예시(개념 코드)입니다.
import crypto from 'crypto'
let jwksCache = { fetchedAt: 0, keys: [] }
const TTL_MS = 5 * 60 * 1000
async function fetchJwks(force = false) {
const now = Date.now()
if (!force && jwksCache.keys.length && now - jwksCache.fetchedAt < TTL_MS) {
return jwksCache.keys
}
const res = await fetch('https://YOUR_IDP/.well-known/jwks.json', {
headers: { 'accept': 'application/json' },
})
if (!res.ok) throw new Error(`jwks fetch failed: ${res.status}`)
const body = await res.json()
jwksCache = { fetchedAt: now, keys: body.keys ?? [] }
return jwksCache.keys
}
function findJwkByKid(keys, kid) {
return keys.find(k => k.kid === kid)
}
async function getJwkForKid(kid) {
let keys = await fetchJwks(false)
let jwk = findJwkByKid(keys, kid)
if (jwk) return jwk
// 키 롤오버/캐시 스테일 가능성: 강제 갱신
keys = await fetchJwks(true)
jwk = findJwkByKid(keys, kid)
if (!jwk) throw new Error(`kid not found after refresh: ${kid}`)
return jwk
}
// 실제 검증은 라이브러리 사용 권장(예: jose)
이 방식은 “롤오버 직후”의 오류를 자동으로 흡수합니다.
TTL은 얼마나?
- 너무 길면 롤오버 때 장애가 길어집니다.
- 너무 짧으면 IdP에 과도한 트래픽이 갑니다.
실무에서는 보통 5~15분 선에서 시작하고, kid not found 시 즉시 갱신 로직으로 안정성을 확보합니다.
가장 흔한 원인 2: 멀티 인스턴스에서 캐시 불일치(배포/스케일링)
서버가 여러 대면 각 인스턴스가 제각각 JWKS를 캐시합니다. 롤오버 직후에는 어떤 인스턴스는 최신, 어떤 인스턴스는 구버전일 수 있습니다.
해결책은 3가지가 흔합니다.
- 애플리케이션 레벨에서
kid not found시 강제 갱신(가장 간단) - 공유 캐시(예: Redis)에 JWKS 캐시 저장
- API Gateway/Edge에서 JWT 검증을 위임(중앙화)
공유 캐시를 쓰면 동시성 제어도 중요합니다. 예를 들어 “여러 요청이 동시에 들어와 모두 JWKS를 갱신”하면 IdP에 순간 부하가 커집니다. 이를 막으려면 single-flight(동일 키로 동시 요청을 1회 fetch로 합치기) 패턴이 유용합니다.
가장 흔한 원인 3: 잘못된 JWKS URL(issuer/테넌트 혼선)
환경이 dev/stage/prod로 나뉘거나, 멀티 테넌트 IdP를 쓰면 다음 실수가 잦습니다.
iss는 A인데 JWKS는 B 테넌트 URL을 조회- 리전이 다른 엔드포인트를 조회
- 커스텀 도메인/기본 도메인을 혼용
체크리스트
- 토큰의
iss값을 확인하고, 해당 issuer의 well-known 문서에서 JWKS URI를 따라가세요.
# OIDC discovery 문서 확인(예시)
curl -s https://YOUR_IDP/.well-known/openid-configuration | jq '.issuer, .jwks_uri'
- 애플리케이션 설정에서 issuer와 jwksUri를 하드코딩하지 말고, 가능하면 discovery 기반으로 구성하세요.
가장 흔한 원인 4: kid 충돌 또는 키셋 동기화 지연
IdP 구현/운영 방식에 따라, 아래 상황이 생길 수 있습니다.
kid가 우연히 충돌(특히 자체 구현에서 단순 증가값/짧은 랜덤)- 롤오버 시旧키를 즉시 제거해, 네트워크 지연이나 캐시 때문에 검증 불가
권장 운영은 “새 키 추가 → 일정 기간 병행 → 旧키 제거”입니다. 본인이 IdP를 운영한다면 롤오버 정책을 점검하세요.
알고리즘 관련 함정: alg는 반드시 allowlist로 제한
JWT 헤더의 alg를 그대로 신뢰하면 알고리즘 다운그레이드 공격이 가능해집니다. 검증 시에는 다음을 강제하세요.
- 기대 알고리즘이
RS256이면, 토큰도RS256만 허용 none은 무조건 거부
jose 라이브러리를 사용할 때도 options로 명시하는 형태가 안전합니다.
import { jwtVerify, createRemoteJWKSet } from 'jose'
const JWKS = createRemoteJWKSet(new URL('https://YOUR_IDP/.well-known/jwks.json'))
export async function verify(token) {
const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
issuer: 'https://YOUR_ISSUER',
audience: 'YOUR_AUDIENCE',
algorithms: ['RS256'],
})
return { payload, header: protectedHeader }
}
여기서도 롤오버 순간 문제가 난다면, 원격 JWKS fetch의 캐시/재시도 정책을 라이브러리 수준에서 어떻게 제공하는지 확인하고, 필요하면 앞서 설명한 “kid 미스 시 강제 갱신” 패턴을 적용하세요.
운영에서 자주 놓치는 포인트: HTTP 캐시 헤더와 프록시
JWKS 엔드포인트는 종종 Cache-Control/ETag를 제공합니다. 하지만 다음 구성에서 의도치 않은 캐싱이 발생합니다.
- 사내 프록시가 JWKS 응답을 과도하게 캐시
- CDN이 JWKS를 캐시(설정 실수)
- 서버의 HTTP 클라이언트가 keep-alive/캐시 정책을 다르게 적용
대응:
- JWKS는 “짧은 TTL + 조건부 요청(ETag)” 조합이 이상적
- 장애 시점에 실제로 어떤 응답 헤더가 오는지 확인
curl -I https://YOUR_IDP/.well-known/jwks.json
장애 대응 플레이북: 10분 안에 수습하는 순서
- 실패한 토큰 1개 확보(로그/리퀘스트에서)
- 토큰 헤더의
kid,alg확인 - 토큰 클레임의
iss,aud,exp확인 - discovery 문서에서
jwks_uri확인 - JWKS에서
kid존재 여부 확인 - 서버의 JWKS 캐시 TTL/갱신 로직 확인(
kid not found시 강제 갱신 유무) - 멀티 인스턴스면 특정 인스턴스에서만 실패하는지 확인(로드밸런서 로그)
- IdP 롤오버 이벤트/공지 확인
- 임시 완화: JWKS 캐시 무효화(재시작/캐시 flush) + TTL 단축
- 재발 방지: single-flight, 공유 캐시, 모니터링 추가
로그/모니터링 권장 사항
JWT 검증 실패를 “한 줄 에러”로만 남기면 원인 규명이 오래 걸립니다. 다음 필드는 개인정보/보안에 유의하면서 구조화 로그로 남기면 좋습니다.
kid,algiss(가능하면)- 실패 유형(키 미발견 vs 서명불일치 vs 클레임 불일치)
- JWKS 캐시 상태(캐시 hit/miss, fetchedAt, TTL)
- JWKS fetch 실패 시 HTTP 상태코드
단, 토큰 원문 전체를 로그로 남기는 것은 위험합니다. 필요하다면 토큰의 해시(sha256)만 남기고, 원문은 보안 채널로만 취급하세요.
import crypto from 'crypto'
function tokenFingerprint(token) {
return crypto.createHash('sha256').update(token).digest('hex')
}
키 롤오버를 “장애”가 아니라 “평상시 이벤트”로 만들기
키 롤오버는 보안상 정상적인 운영 이벤트입니다. 문제는 롤오버 자체가 아니라, 검증 시스템이 이를 흡수하지 못하는 설계에 있습니다.
kid not found시 JWKS 강제 갱신 + 1회 재검증- 적절한 TTL(5~15분)과 과도한 동시 갱신 방지
- issuer/jwksUri 혼선 방지(discovery 기반)
algallowlist 강제
이 4가지만 갖춰도 “갑자기 JWT가 전부 터지는” 유형의 장애는 대부분 사라집니다.
추가로, 운영 중 외부 API 호출 실패(권한/쿼터/네트워크)와 비슷한 방식으로 관측과 재시도 전략을 세우면 안정성이 올라갑니다. 예를 들어 인증 서버나 IdP 호출이 간헐적으로 실패할 때의 접근은 AWS Bedrock InvokeModel 403·Throttling 해결 - IAM·VPC·쿼터에서 다룬 “원인 분리와 관측 지표” 관점이 그대로 적용됩니다.
마무리 체크리스트
- 토큰 헤더
kid를 JWKS에서 찾을 수 있는가 -
kid not found시 JWKS를 즉시 갱신하고 재시도하는가 - issuer와 jwksUri가 discovery 문서와 일치하는가
-
alg를 allowlist로 제한했는가 - 멀티 인스턴스에서 캐시/갱신이 일관적인가
- JWKS 응답의 캐시 헤더/프록시 캐시를 점검했는가
위 항목을 적용하면, JWT 검증 실패의 80% 이상을 차지하는 JWKS 롤오버·kid 문제를 빠르게 수습하고 재발을 줄일 수 있습니다.