Published on

JWT 서명 검증 실패 - kid·JWKS 캐시·키회전

Authors

서명 검증 실패(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 failed
  • No matching JWK found for kid=...
  • kid not found in JWKS
  • invalid signature (라이브러리별 메시지 상이)
  • 간헐적 401/403 (특히 배포 직후, IdP 키 회전 직후)

여기서 중요한 단서는 **"간헐적"**입니다. 항상 실패한다면 URL 오타, 알고리즘 불일치, issuer/audience 불일치처럼 비교적 단순한 설정 문제일 확률이 높습니다. 반면 간헐적이면 캐시/회전/전파 지연을 강하게 의심해야 합니다.

JWT 서명 검증의 핵심: kid → JWKS에서 키 선택

OIDC/JWT 검증의 일반적인 흐름은 다음과 같습니다.

  1. JWT 헤더에서 kidalg를 읽는다.
  2. iss(issuer)에 해당하는 JWKS endpoint에서 공개키 목록을 가져온다.
  3. kid가 일치하는 JWK를 선택한다.
  4. 해당 공개키로 서명을 검증한다.

즉, 검증 실패는 대개 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 헤더 존중

권장 동작(실전)

  1. 평소에는 JWKS를 TTL 기반으로 캐시
  2. 검증 중 kid가 없으면 즉시 JWKS를 1회 강제 갱신
  3. 갱신 후에도 없으면 그때 실패 처리(정상적인 거절)

이 패턴만 적용해도 "키 회전 직후"의 간헐적 장애가 극적으로 줄어듭니다.

원인 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분 트리아지

  1. 실패한 요청의 JWT에서 kid를 추출
  2. JWKS endpoint에 직접 요청해 해당 kid 존재 여부 확인
  3. 서비스 인스턴스별로 JWKS 캐시 갱신 시각 비교(노드 간 편차 확인)
  4. IdP 키 회전 이벤트/변경 이력 확인
  5. 임시 완화책: 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"을 대부분 제거하고, 장애가 나더라도 원인을 빠르게 특정할 수 있습니다.