Published on

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

Authors

운영 중이던 인증이 어느 날부터 간헐적으로 invalid signature, JWT signature verification failed, kid not found 같은 에러를 뿜기 시작하면 대부분 원인은 세 가지로 수렴합니다.

  • 토큰 헤더의 kid가 검증 서버가 알고 있는 키와 매칭되지 않음
  • JWKS(JSON Web Key Set) 조회 결과를 캐싱해둔 값이 오래되어 최신 키를 못 봄
  • IdP(발급자)가 키 회전을 했는데 소비자(검증자)가 그 전환 구간을 안전하게 처리하지 못함

이 글은 “왜 이런 일이 생기는지”를 프로토콜 관점에서 설명하고, Node.js 기준으로 안전한 검증 구현(캐시·리트라이·키회전 대응)을 코드로 제시합니다.

JWT 서명 검증이 실패하는 전형적인 시나리오

1) kid가 무엇이고 왜 중요한가

JWT는 보통 header.payload.signature 구조이며, 헤더에는 다음 같은 필드가 들어갑니다.

  • alg: 서명 알고리즘 (예: RS256)
  • kid: Key ID. “이 토큰을 어떤 공개키로 검증해야 하는지”를 가리키는 식별자

검증자는 JWT의 kid를 읽고, JWKS 엔드포인트에서 동일한 kid를 가진 JWK를 찾아 공개키로 변환한 뒤 서명을 검증합니다.

문제는 kid가 바뀌는 순간입니다. IdP가 키를 회전하면 새 토큰은 새 kid로 발급되는데, 검증 측이 여전히 예전 JWKS 캐시만 들고 있으면 kid를 찾지 못하거나(= kid not found) 잘못된 키로 검증해서 invalid signature가 납니다.

2) JWKS 캐시가 만드는 “간헐적 실패”

JWKS는 보통 CDN/HTTP로 제공됩니다. 검증 서버는 매 요청마다 JWKS를 가져오면 느리니 캐시합니다.

  • 캐시 TTL이 너무 길다: 키 회전 직후 새 kid를 모름
  • 캐시 무효화가 없다: kid not found가 나도 캐시를 계속 씀
  • 멀티 인스턴스 환경에서 각자 다른 시점의 캐시를 가짐: 어떤 인스턴스는 성공, 어떤 인스턴스는 실패

특히 서버리스/컨테이너 환경에서는 콜드스타트 때 JWKS를 가져오고 이후 메모리에 오래 들고 있는 패턴이 흔합니다. 트래픽이 몰릴 때 콜드스타트가 늘면 “어떤 인스턴스는 최신, 어떤 인스턴스는 구버전”이 섞여 장애가 더 랜덤하게 보입니다. 이 관점은 GCP Cloud Run 503·콜드스타트 줄이는 튜닝에서 다룬 패턴과 비슷합니다.

3) 키 회전(rotate) 구간에서 생기는 레이스

이상적인 키 회전은 다음 순서를 지킵니다.

  1. JWKS에 새 키를 “추가”한다 (기존 키는 유지)
  2. kid로 토큰 발급을 시작한다
  3. 기존 토큰의 만료 시간이 충분히 지난 뒤, 오래된 키를 JWKS에서 제거한다

하지만 실제로는 1~2가 거의 동시에 일어나거나, 캐시/CDN 전파 지연 때문에 2가 먼저 관측되는 것처럼 보일 수 있습니다. 이때 검증자는 “토큰은 새 kid인데 JWKS에는 아직 없다” 상태를 만나게 됩니다.

빠른 진단 체크리스트

1) 토큰 헤더에서 kid 확인

운영 로그에 토큰 전체를 남기면 위험하니, 헤더만 안전하게 디코딩해 kid를 확인합니다.

// Node.js: JWT 헤더만 base64url 디코드
function decodeJwtHeader(token) {
  const [headerB64] = token.split('.')
  const json = Buffer.from(headerB64, 'base64url').toString('utf8')
  return JSON.parse(json)
}

const { kid, alg, typ } = decodeJwtHeader(token)
console.log({ kid, alg, typ })
  • alg가 기대값(예: RS256)인지 확인
  • kid가 비어있거나 너무 자주 바뀌는지 확인

2) JWKS에서 해당 kid가 실제로 존재하는지 확인

JWKS는 보통 https://issuer/.well-known/jwks.json 형태입니다.

curl -s https://YOUR_ISSUER/.well-known/jwks.json | jq '.keys[] | {kid, kty, alg, use}'

여기서 토큰의 kid가 없다면, 거의 확실히 “회전 직후 전파 지연” 또는 “검증자가 잘못된 issuer의 JWKS를 보고 있음”입니다.

3) issuer/audience 불일치도 같이 확인

서명 검증 실패처럼 보이지만 실제로는 클레임 검증 단계에서 터지는 경우도 많습니다.

  • iss(issuer)가 환경별로 다른데 설정이 섞임
  • aud(audience)가 API별로 다른데 하나로 고정해 검증

이 경우 에러 메시지가 라이브러리마다 달라 혼동됩니다. 반드시 서명 검증과 클레임 검증을 분리해서 로깅하세요.

안전한 검증 구현 원칙 6가지

원칙 1) alg 고정(알고리즘 혼동 공격 방지)

토큰 헤더의 alg를 “그대로 믿고” 동적으로 처리하면 위험합니다. 서버는 기대하는 알고리즘만 허용해야 합니다.

  • 예: RS256만 허용

원칙 2) JWKS 캐시는 하되, kid not found면 즉시 리프레시

정상 상황에서는 캐시를 쓰고, 예외 상황에서는 캐시를 무효화하고 JWKS를 다시 가져오는 전략이 실전에서 가장 효과적입니다.

원칙 3) 키 회전 구간을 고려해 “1회 재시도”

회전 직후 전파 지연은 수 초 단위로 발생할 수 있습니다.

  • 첫 시도에서 kid not found 또는 서명 실패
  • JWKS 강제 새로고침
  • 짧은 지연 후 1회 재시도

무한 재시도는 DoS 벡터가 되므로 금지입니다.

원칙 4) 멀티 인스턴스면 공유 캐시(옵션)

인스턴스별 메모리 캐시는 간단하지만, 대규모 환경에서는 Redis 같은 공유 캐시가 장애를 줄입니다.

  • 단, JWKS는 공개 정보라 캐시 일관성만 맞추면 됨
  • 캐시 TTL은 짧게(예: 5~15분) + kid not found 시 즉시 갱신

원칙 5) JWKS 응답의 캐시 헤더를 존중

IdP가 Cache-Control: max-age=...를 주는 경우가 많습니다. 이를 참고해 TTL을 정하면 회전 정책과 더 잘 맞습니다.

원칙 6) 관측성: kid, iss, JWKS fetch 여부를 구조화 로그로

장애 시 “어떤 kid가 실패했는지”, “캐시 hit인지 miss인지”, “JWKS를 언제 갱신했는지”가 없으면 원인 규명이 길어집니다.

Node.js 예제: JWKS 캐시 + 키 회전 대응 검증

아래 예제는 jose 기반으로 다음을 구현합니다.

  • RS256만 허용
  • 메모리 캐시(TTL)
  • kid 미발견 또는 서명 실패 시 JWKS 강제 갱신 후 1회 재시도
  • iss, aud 검증

설치:

npm i jose

구현:

import { jwtVerify, createRemoteJWKSet } from 'jose'

const ISSUER = process.env.JWT_ISSUER
const AUDIENCE = process.env.JWT_AUDIENCE

// JWKS URL 예: https://issuer/.well-known/jwks.json
const JWKS_URL = new URL(`${ISSUER}/.well-known/jwks.json`)

// jose의 RemoteJWKSet은 내부 캐시를 갖지만,
// 키 회전 대응을 위해 "강제 새로고침" 전략을 레이어로 추가한다.
function buildJwksClient() {
  return createRemoteJWKSet(JWKS_URL)
}

let jwks = buildJwksClient()
let lastRefreshAt = 0

const MIN_REFRESH_INTERVAL_MS = 3_000 // 과도한 갱신 방지

async function forceRefreshJwks() {
  const now = Date.now()
  if (now - lastRefreshAt < MIN_REFRESH_INTERVAL_MS) return

  // createRemoteJWKSet은 URL 기반으로 동작하므로
  // 인스턴스를 재생성해 캐시를 논리적으로 리셋한다.
  jwks = buildJwksClient()
  lastRefreshAt = now
}

export async function verifyAccessToken(token) {
  const options = {
    issuer: ISSUER,
    audience: AUDIENCE,
    algorithms: ['RS256'],
  }

  try {
    const result = await jwtVerify(token, jwks, options)
    return result.payload
  } catch (err) {
    const msg = String(err && err.message ? err.message : err)

    // kid not found / signature 관련 에러는 회전 구간일 수 있으니 1회 리프레시 후 재시도
    const shouldRetry =
      msg.includes('no applicable key') ||
      msg.includes('JWKS') ||
      msg.toLowerCase().includes('kid') ||
      msg.toLowerCase().includes('signature')

    if (!shouldRetry) throw err

    await forceRefreshJwks()

    // 짧은 지연은 CDN 전파/동시성 레이스 완화에 도움이 된다.
    await new Promise((r) => setTimeout(r, 150))

    const result2 = await jwtVerify(token, jwks, options)
    return result2.payload
  }
}

이 코드가 해결하는 것 / 해결하지 못하는 것

  • 해결: JWKS 캐시가 오래되어 새 kid를 못 보는 문제
  • 해결: 회전 직후 전파 지연으로 인한 일시적 kid not found
  • 미해결: 토큰 자체가 다른 issuer에서 발급된 경우(설정 오류)
  • 미해결: 토큰이 변조됐거나 공격 트래픽인 경우(정상적으로 실패해야 함)

운영에서 자주 겪는 함정들

함정 1) 같은 환경에 issuer가 2개 섞임

모바일/웹/서버가 서로 다른 issuer를 쓰다가, API가 한쪽 JWKS만 보도록 고정되는 경우가 있습니다.

  • 증상: 특정 클라이언트에서만 서명 실패
  • 해결: iss를 로그로 수집하고, 테넌트별 issuer 라우팅 또는 멀티 issuer 지원

함정 2) 프록시/방화벽이 JWKS를 캐시하거나 차단

기업망 프록시가 jwks.json을 의도치 않게 캐시해 오래된 키를 계속 내려주는 사례가 있습니다.

  • 해결: JWKS 도메인 allowlist, 프록시 캐시 정책 점검, Cache-Control 준수

함정 3) 시간 동기화(NTP) 문제는 “서명 실패”처럼 보이기도 함

서명 자체는 맞는데, exp/nbf 검증에서 실패합니다.

  • 증상: jwt expired 또는 not active yet이지만 라이브러리 래핑으로 뭉개져 서명 실패처럼 보임
  • 해결: 서버 시간 동기화, clockTolerance(허용 오차) 최소 범위로 적용

함정 4) 콜드스타트가 많을수록 JWKS fetch가 병목이 됨

인스턴스가 자주 뜨고 지면 JWKS를 자주 가져오게 됩니다.

장애 대응 플레이북(현장용)

1) 즉시 확인

  • 실패 로그에서 kid, iss, aud, 에러 타입을 분리해 확인
  • JWKS에서 해당 kid가 존재하는지 curl로 확인

2) 임시 완화

  • kid not found일 때 JWKS 강제 갱신 + 1회 재시도 배포
  • 캐시 TTL을 과도하게 길게 잡았다면 단축

3) 근본 해결

  • IdP 키 회전 정책 확인(새 키 추가 후 기존 키 유지 기간)
  • JWKS에 대한 캐시 헤더/전파 정책 점검
  • 멀티 리전/멀티 인스턴스 환경이면 공유 캐시 또는 갱신 동기화 전략 도입

마무리: kid는 힌트이고, 캐시는 함정이다

JWT 검증에서 kid는 “어떤 키로 검증할지”를 알려주는 힌트일 뿐이고, 실제 장애의 핵심은 대개 JWKS 캐시와 키 회전 타이밍의 불일치입니다.

  • 정상 시: 캐시로 비용을 줄이고
  • 예외 시: kid 기반으로 즉시 갱신하고
  • 회전 구간: 1회 재시도로 레이스를 흡수

이 세 가지 원칙만 지켜도 운영에서 체감하는 invalid signature 류 장애의 대부분을 빠르게 줄일 수 있습니다.