- Published on
JWT 서명 검증 실패 - kid·JWKS 캐시·키회전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서명 검증 실패(signature verification failed)는 JWT 기반 인증/인가에서 가장 치명적이면서도, 원인이 한두 가지로 깔끔하게 떨어지지 않는 장애 유형입니다. 특히 운영 환경에서 간헐적으로만 발생한다면 대개 kid(Key ID) 선택 로직, JWKS(JSON Web Key Set) 캐시, 그리고 IdP(Authorization Server)의 키 회전(key rotation) 타이밍이 얽혀 있습니다.
이 글에서는 **"왜 어떤 토큰은 되고 어떤 토큰은 안 되는가"**를 중심으로, 재현 가능한 진단 절차와 안전한 해결책(캐시 전략, 강제 리프레시, 멀티키 검증, 관측 지표)을 정리합니다.
> OAuth/OIDC 흐름 자체에서 invalid_grant 등 발급 단계 오류를 먼저 점검해야 한다면 OAuth2 PKCE에서 invalid_grant 뜰 때 7가지 점검도 함께 참고하세요.
문제의 전형적인 증상
다음과 같은 로그/에러가 반복됩니다.
JWT signature verification failedNo matching JWK found for kid=...kid not found in JWKSinvalid signature(라이브러리별 메시지 상이)- 간헐적 401/403 (특히 배포 직후, IdP 키 회전 직후)
여기서 중요한 단서는 **"간헐적"**입니다. 항상 실패한다면 URL 오타, 알고리즘 불일치, issuer/audience 불일치처럼 비교적 단순한 설정 문제일 확률이 높습니다. 반면 간헐적이면 캐시/회전/전파 지연을 강하게 의심해야 합니다.
JWT 서명 검증의 핵심: kid → JWKS에서 키 선택
OIDC/JWT 검증의 일반적인 흐름은 다음과 같습니다.
- JWT 헤더에서
kid와alg를 읽는다. iss(issuer)에 해당하는 JWKS endpoint에서 공개키 목록을 가져온다.kid가 일치하는 JWK를 선택한다.- 해당 공개키로 서명을 검증한다.
즉, 검증 실패는 대개 2~3번(키 조회/선택)에서 시작됩니다.
JWT 헤더 예시
{
"alg": "RS256",
"typ": "JWT",
"kid": "b7f1c2d9"
}
kid는 "이 토큰은 이 키로 서명했으니, 검증할 때도 이 키를 써라"라는 힌트입니다. 하지만 이 힌트가 유효하려면, 검증자가 최신 JWKS를 가지고 있어야 합니다.
원인 1) kid 불일치: IdP가 키를 바꿨는데 캐시는 예전
가장 흔한 시나리오는 다음입니다.
- IdP가 새 키로 서명하기 시작(새
kid발급) - 리소스 서버(검증자)는 JWKS를 캐시해둔 상태
- 캐시에 새
kid가 없으므로No matching JWK또는 서명 검증 실패
진단 체크리스트
- 실패한 토큰의 헤더
kid를 추출했는가? - 현재 서비스가 들고 있는 JWKS 캐시에 해당
kid가 존재하는가? - JWKS endpoint를 직접 호출해보면 해당
kid가 존재하는가? - 캐시 TTL이 너무 긴가? (예: 24시간)
토큰에서 kid 추출(로컬에서 빠르게)
TOKEN="eyJhbGciOiJSUzI1NiIsImtpZCI6ImI3ZjFjMmQ5In0.eyJzdWIiOiIxMjMifQ.X..."
python - << 'PY'
import base64, json, os
h = os.environ['TOKEN'].split('.')[0]
h += '=' * (-len(h) % 4)
print(json.dumps(json.loads(base64.urlsafe_b64decode(h)), indent=2))
PY
원인 2) JWKS 캐시 전략이 부적절(또는 무효화가 없음)
JWKS는 매 요청마다 가져오면 느리고(네트워크 비용), 장애에 취약하므로 캐시가 필수입니다. 문제는 많은 구현이 **"캐시가 필요"**까지만 하고, 다음을 빠뜨립니다.
- kid 미스 시 강제 리프레시
- stale-while-revalidate(만료된 캐시로 일단 검증 시도 + 백그라운드 갱신)
- 동시성 제어(thundering herd 방지)
- IdP의 Cache-Control 헤더 존중
권장 동작(실전)
- 평소에는 JWKS를 TTL 기반으로 캐시
- 검증 중
kid가 없으면 즉시 JWKS를 1회 강제 갱신 - 갱신 후에도 없으면 그때 실패 처리(정상적인 거절)
이 패턴만 적용해도 "키 회전 직후"의 간헐적 장애가 극적으로 줄어듭니다.
원인 3) 키 회전 타이밍과 전파 지연(멀티 리전/멀티 노드)
IdP가 키를 회전하는 방식은 보통 다음 중 하나입니다.
- 새 키 추가 → 새 키로 서명 시작 → 일정 기간 후 옛 키 제거
- (나쁜 패턴) 옛 키 제거와 새 키 적용이 거의 동시에 발생
여기서 전파 지연이 끼면 문제가 커집니다.
- 리소스 서버 A는 새 JWKS를 받아서 통과
- 리소스 서버 B는 예전 JWKS 캐시로 실패
또는 반대로,
- IdP가 옛 키를 JWKS에서 제거했는데
- 아직 그 옛 키로 서명된 토큰이 클라이언트/게이트웨이/큐에 남아있어
- 유효 기간 내인데도 검증이 실패
해결의 핵심: "키 제거"는 토큰 TTL 이후에
운영 원칙으로는:
- 최소한 Access Token의 최대 TTL + clock skew + 전파 시간 만큼은 옛 키를 JWKS에 유지
- 가능하면 Refresh Token 교환/세션 정책까지 고려
원인 4) alg 혼동/알고리즘 공격 방어 미흡
서명 실패처럼 보이지만 사실은 다음 케이스일 수 있습니다.
- IdP는
RS256인데 검증 라이브러리가HS256로 설정되어 있음 - 라이브러리가
alg=none또는 예상치 못한 알고리즘을 허용
반드시 허용 알고리즘을 고정하고, 토큰 헤더의 alg를 신뢰하지 말아야 합니다.
구현 예제: Node.js에서 JWKS 캐시 + kid 미스 시 리프레시
아래 예시는 jose 라이브러리를 사용해 JWKS를 캐시하면서도, kid 미스 시 강제 갱신하는 패턴을 보여줍니다.
import { jwtVerify, createRemoteJWKSet } from 'jose'
const issuer = 'https://idp.example.com/'
const jwksUrl = new URL('/.well-known/jwks.json', issuer)
// 기본적으로 캐시/재시도 로직이 있으나,
// "kid miss" 시의 강제 갱신은 구현에 따라 추가가 필요할 수 있습니다.
const JWKS = createRemoteJWKSet(jwksUrl, {
// jose 버전에 따라 옵션명이 다를 수 있으니 문서 확인
// cacheMaxAge: 10 * 60 * 1000,
// cooldownDuration: 30_000,
// timeoutDuration: 2_000,
})
export async function verifyAccessToken(token) {
const options = {
issuer,
audience: 'api://my-service',
algorithms: ['RS256'],
}
try {
return await jwtVerify(token, JWKS, options)
} catch (e) {
// kid miss/키회전 직후를 대비한 "한 번만" 강제 리프레시 전략을 권장
// (구현체에 따라 JWKS 내부 캐시를 날리는 별도 훅이 없을 수 있어,
// 이 경우 JWKS 인스턴스를 재생성하는 방식으로 우회)
if (String(e?.code || e?.message).includes('JWK') || String(e?.message).includes('kid')) {
const JWKS2 = createRemoteJWKSet(jwksUrl)
return await jwtVerify(token, JWKS2, options)
}
throw e
}
}
포인트는 실패 시 무조건 재시도가 아니라, kid 관련 실패에 한해 1회만 리프레시/재시도를 하는 것입니다. 무한 재시도는 IdP 장애 시 연쇄 장애를 유발합니다.
> Edge 런타임(예: Next.js Edge)에서 암호화 API 제약으로 JWT 검증이 흔들릴 때는 Next.js 14 Edge 런타임 crypto is not defined 해결법처럼 런타임 제약도 함께 점검해야 합니다.
운영에서 꼭 넣어야 할 관측(Observability) 항목
서명 검증 실패를 "사용자 401"로만 보면 원인을 못 잡습니다. 다음을 메트릭/로그로 남기면 재현이 쉬워집니다.
로그에 남길 것(개인정보 제외)
iss,aud- JWT 헤더의
kid,alg - 검증 실패 유형 분류(예: kid_miss / bad_signature / token_expired)
- JWKS 캐시 상태(캐시 히트/미스, 마지막 갱신 시각)
- JWKS fetch 실패 시 HTTP status, latency
메트릭 예시
jwt_verify_fail_total{reason="kid_miss"}jwks_refresh_total{reason="kid_miss"}jwks_fetch_error_total{status="500"}jwks_cache_age_seconds
이 지표만 있어도 "키 회전 직후 5분 동안 kid_miss가 급증" 같은 패턴이 바로 보입니다.
실전 대응 시나리오: 장애 발생 시 10분 트리아지
- 실패한 요청의 JWT에서
kid를 추출 - JWKS endpoint에 직접 요청해 해당
kid존재 여부 확인 - 서비스 인스턴스별로 JWKS 캐시 갱신 시각 비교(노드 간 편차 확인)
- IdP 키 회전 이벤트/변경 이력 확인
- 임시 완화책: kid 미스 시 강제 리프레시(또는 캐시 TTL 단축)
클러스터 환경(EKS 등)에서 네트워크 이슈로 JWKS fetch 자체가 간헐적으로 실패하면 서명 검증 실패처럼 보일 수 있습니다. 이 경우에는 TLS/네트워크 레이어도 같이 확인해야 하며, 유사한 트러블슈팅 접근은 EKS TLS handshake timeout 해결 - IRSA·VPC·CoreDNS의 네트워크 진단 흐름이 도움이 됩니다.
권장 아키텍처 패턴: "검증"과 "키 관리"를 분리
트래픽이 크거나, 다수의 마이크로서비스가 각자 JWKS를 가져가는 구조라면 다음을 고려할 만합니다.
- JWKS 프록시/캐시 서비스를 내부에 두고, 모든 서비스가 이를 통해 키를 조회
- 또는 API Gateway/Envoy에서 JWT 검증을 수행해 애플리케이션에서 검증 부담 제거
장점:
- 캐시 정책을 중앙에서 통제
- 키 회전 시 전파 지연/노드 편차 감소
- 관측 지점이 단일화
단점:
- 프록시가 SPOF가 되지 않도록 HA 필요
- 게이트웨이에서의 정책/클레임 전달 설계 필요
결론: kid·캐시·회전은 "세트"로 설계해야 한다
JWT 서명 검증 실패를 줄이려면 단순히 "JWKS를 캐시한다"가 아니라, 아래 3가지를 세트로 구현해야 합니다.
kid미스 시 1회 강제 리프레시- JWKS 캐시의 TTL + 동시성 제어 + 장애 시 폴백
- 키 회전 시 옛 키 유지 기간을 토큰 TTL 이후까지 확보
이 원칙을 적용하면, 키 회전이 있는 환경에서도 "간헐적 401"을 대부분 제거하고, 장애가 나더라도 원인을 빠르게 특정할 수 있습니다.