Published on

Node.js JWT 검증 실패 - kid·JWKS 캐시 만료 대응

Authors

운영 중인 Node.js API에서 JWT 검증이 간헐적으로 실패하며 401이 튀기 시작하는 경우가 있습니다. 특히 로그에 kid 관련 메시지가 보이거나, 특정 시점(아이덴티티 서버 재시작, 배포, 키 회전 이후)부터 실패율이 급증한다면 JWKS 캐시 만료 및 키 회전(rotate) 대응 부재가 거의 항상 원인입니다.

이 글에서는 다음을 다룹니다.

  • kid가 무엇이고, 왜 kid 불일치가 발생하는지
  • Node.js에서 JWKS를 가져와 JWT를 검증할 때 흔히 망가지는 캐시 패턴
  • 캐시 만료, 키 회전, 네트워크 장애 상황에서도 안정적으로 동작하는 검증 로직
  • 운영에서 재현·진단할 수 있는 로그/메트릭 포인트

관련 주제를 더 깊게 보려면 내부 글인 Keycloak JWT kid 불일치 401 - JWKS 캐시·회전 대응도 함께 참고하면 좋습니다.

증상: 왜 갑자기 kid 때문에 401이 늘어날까

JWT 헤더에는 보통 다음처럼 kid가 들어갑니다.

  • kid: 토큰을 서명한 공개키를 식별하는 키 ID
  • alg: 서명 알고리즘(예: RS256)

리소스 서버(API)는 토큰을 검증할 때,

  1. 토큰 헤더의 kid를 읽고
  2. JWKS(JSON Web Key Set) 엔드포인트에서 공개키 목록을 가져온 뒤
  3. 동일한 kid를 가진 공개키를 찾아
  4. 그 키로 서명을 검증합니다.

문제는 아이덴티티 서버가 키를 회전하면, 새 토큰은 새 kid로 발급되는데 리소스 서버가 예전 JWKS를 캐시하고 있으면 새 kid를 찾지 못해 검증이 실패합니다.

대표적인 에러 형태는 다음 중 하나입니다.

  • Signing key not found (해당 kid의 키를 JWKS에서 못 찾음)
  • JWKS endpoint returned keys, but none match kid
  • JsonWebTokenError: invalid signature (키를 잘못 골랐거나, 키가 바뀌었는데 캐시가 꼬인 상태)

원인 1: JWKS 캐시 TTL이 너무 길거나 무한 캐시

많은 구현이 “JWKS는 자주 안 바뀌겠지”라는 전제로 캐시 TTL을 길게 잡거나, 프로세스 시작 시 한 번만 로드합니다. 하지만 현실에서는 다음 이벤트가 생각보다 자주 발생합니다.

  • IdP(예: Keycloak, Cognito, Auth0) 설정 변경
  • 키 회전 정책에 따른 정기 rotate
  • 장애 복구/스케일링 과정에서 키셋이 바뀌거나, JWKS 서빙이 잠시 불안정

이때 TTL이 길면 새 kid를 반영하기까지 서비스가 계속 401을 내며 장애로 이어집니다.

원인 2: 캐시 만료 시점에 동시 갱신 폭주(Thundering herd)

캐시 TTL을 짧게 잡았더라도, 만료 시점에 트래픽이 몰리면 각 요청이 동시에 JWKS를 갱신하려고 하며 IdP에 과부하를 줄 수 있습니다. 그 결과 JWKS 요청이 타임아웃 또는 5xx를 내고, 그 순간 들어온 JWT 검증이 연쇄 실패합니다.

이 문제는 Kubernetes 환경에서 특히 흔합니다. Pod가 여러 개면 만료 타이밍이 우연히 겹치거나, 같은 시점에 롤링 업데이트로 재시작되면서 JWKS 요청이 폭발합니다.

원인 3: 키 회전 직후 “새 토큰은 새 키, 기존 토큰은 구 키” 공존 구간

키 회전이 발생하면 보통 일정 시간 동안은 다음이 공존합니다.

  • 이미 발급된 토큰: 구 kid로 서명
  • 새로 발급되는 토큰: 신 kid로 서명

IdP가 JWKS에서 구 키를 너무 빨리 제거하거나, 리소스 서버가 JWKS를 갱신하면서 구 키를 잃어버리면 “아직 유효한 구 토큰”이 갑자기 실패합니다.

따라서 IdP 측 키 보존 기간리소스 서버의 캐시 정책은 함께 설계해야 합니다.

설계 목표: JWKS 캐시는 “빠르게 갱신하되, 폭주하지 않게”

안정적인 목표는 다음 3가지를 동시에 만족하는 것입니다.

  1. kid 미스가 나면 즉시 JWKS를 재조회(강제 리프레시)한다
  2. 평상시에는 캐시를 사용해 IdP 호출을 최소화한다
  3. 만료/리프레시 구간에 동시 갱신 폭주를 막는다(단일 플라이트)

구현 예시: jose + createRemoteJWKSet로 안전하게 검증하기

Node.js에서 JWT 검증은 jose 라이브러리를 많이 사용합니다. josecreateRemoteJWKSet은 원격 JWKS를 가져와 키를 선택하고, 내부적으로 캐시/재사용을 지원합니다.

아래 예시는 기본 검증 흐름입니다.

import { jwtVerify, createRemoteJWKSet } from 'jose'

const issuer = process.env.OIDC_ISSUER!
const audience = process.env.OIDC_AUDIENCE!

// 예: https://idp.example.com/.well-known/jwks.json
const jwksUrl = new URL(process.env.JWKS_URL!)

const JWKS = createRemoteJWKSet(jwksUrl)

export async function verifyAccessToken(authHeader?: string) {
  if (!authHeader?.startsWith('Bearer ')) {
    throw new Error('missing bearer token')
  }

  const token = authHeader.slice('Bearer '.length)

  const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
    issuer,
    audience,
  })

  return { payload, protectedHeader }
}

여기서 중요한 포인트는 “kid 미스가 날 때 어떻게 재시도할 것인가”입니다. createRemoteJWKSet은 상황에 따라 재조회가 발생하지만, 운영에서는 다음을 추가로 보강하는 편이 안전합니다.

  • kid 미스 또는 키 선택 실패 시 강제 리프레시 후 1회 재검증
  • 리프레시 자체는 단일 플라이트로 묶어 폭주 방지

kid 미스 시 강제 리프레시 + 단일 플라이트 패턴

jose는 내부 캐시를 가집니다. 다만 “원격 JWKS를 강제로 새로고침”을 애플리케이션 레벨에서 명시적으로 제어하고 싶다면, 아래처럼 JWKS fetch를 감싼 캐시 레이어를 두는 접근이 실무에서 자주 쓰입니다.

아래 코드는 개념 예시입니다.

  • 캐시 TTL을 짧게(예: 5분)
  • 만료되면 한 번만 갱신하도록 inflight Promise로 묶기
  • 검증 중 kid 불일치가 나면 강제 갱신 후 1회 재시도
import { jwtVerify, importJWK, JWTPayload } from 'jose'

type Jwk = { kid: string; kty: string; n?: string; e?: string; crv?: string; x?: string; y?: string }

type Jwks = { keys: Jwk[] }

const issuer = process.env.OIDC_ISSUER!
const audience = process.env.OIDC_AUDIENCE!
const jwksUrl = process.env.JWKS_URL!

let cached: { jwks: Jwks; expiresAt: number } | null = null
let inflight: Promise<Jwks> | null = null

const TTL_MS = 5 * 60 * 1000

async function fetchJwks(): Promise<Jwks> {
  const res = await fetch(jwksUrl, {
    headers: { 'accept': 'application/json' },
  })
  if (!res.ok) throw new Error(`jwks fetch failed: ${res.status}`)
  return (await res.json()) as Jwks
}

async function getJwks(forceRefresh = false): Promise<Jwks> {
  const now = Date.now()

  if (!forceRefresh && cached && cached.expiresAt > now) {
    return cached.jwks
  }

  if (!inflight) {
    inflight = (async () => {
      try {
        const jwks = await fetchJwks()
        cached = { jwks, expiresAt: Date.now() + TTL_MS }
        return jwks
      } finally {
        inflight = null
      }
    })()
  }

  return inflight
}

function pickJwkByKid(jwks: Jwks, kid?: string): Jwk {
  if (!kid) throw new Error('missing kid in token header')
  const jwk = jwks.keys.find(k => k.kid === kid)
  if (!jwk) throw new Error(`signing key not found for kid: ${kid}`)
  return jwk
}

export async function verifyJwt(token: string): Promise<{ payload: JWTPayload; kid: string }> {
  // 1차: 캐시 기반
  const firstJwks = await getJwks(false)

  try {
    const header = JSON.parse(Buffer.from(token.split('.')[0], 'base64url').toString('utf8'))
    const kid = header.kid as string | undefined

    const jwk = pickJwkByKid(firstJwks, kid)
    const key = await importJWK(jwk, header.alg)

    const { payload } = await jwtVerify(token, key, { issuer, audience })
    return { payload, kid: kid! }
  } catch (e: any) {
    // kid 미스/서명 실패 등에서 강제 갱신 후 1회 재시도
    const msg = String(e?.message ?? e)
    const shouldRefresh = msg.includes('signing key not found') || msg.includes('invalid signature')

    if (!shouldRefresh) throw e

    const refreshed = await getJwks(true)

    const header = JSON.parse(Buffer.from(token.split('.')[0], 'base64url').toString('utf8'))
    const kid = header.kid as string | undefined
    const jwk = pickJwkByKid(refreshed, kid)
    const key = await importJWK(jwk, header.alg)

    const { payload } = await jwtVerify(token, key, { issuer, audience })
    return { payload, kid: kid! }
  }
}

위 구현에서 주의할 점

  • invalid signature는 진짜 공격/위변조일 수도 있습니다. 다만 키 회전 직후 캐시가 구버전일 때도 발생할 수 있어 “1회만 재시도” 정도로 제한하는 것이 안전합니다.
  • JWKS fetch 실패 시 “기존 캐시를 유지하며 실패를 완화”할지, “엄격히 실패”할지는 보안 요구사항에 따라 다릅니다. 보통은 캐시가 아직 TTL 내면 캐시 사용, TTL이 지났고 fetch도 실패하면 401로 처리합니다.
  • 토큰 헤더 파싱에서 token.split('.') 결과 검증 등 방어 코드를 추가하는 편이 좋습니다.

운영 진단: 로그에 무엇을 남겨야 재현이 쉬워질까

JWT 검증 실패를 디버깅할 때는 “왜 실패했는지”가 아니라 “어떤 kid였고, 당시 JWKS에 어떤 키가 있었는지”가 핵심입니다.

권장 로그 필드:

  • token_kid: 토큰 헤더의 kid
  • jwks_kids: 현재 캐시에 들어있는 kid 목록(너무 길면 샘플링)
  • jwks_cache_age_ms: 캐시 생성 후 경과 시간
  • jwks_refresh: 강제 리프레시 수행 여부
  • issuer, audience: 설정 값(환경별 혼선 방지)
  • 실패 타입: kid_not_found, invalid_signature, exp, nbf, iss_mismatch

또한 메트릭으로는 다음이 효과적입니다.

  • jwks_refresh_total 카운터
  • jwks_fetch_error_total 카운터
  • jwt_verify_fail_total{reason=...} 카운터
  • jwt_verify_latency_ms 히스토그램

자주 놓치는 함정 4가지

1) JWKS URL을 잘못 지정

OIDC의 경우 보통 /.well-known/openid-configuration에서 jwks_uri를 가져오는 것이 안전합니다. 환경별로 URL을 하드코딩하다가 스테이징/프로덕션이 섞이면, 갑자기 kid가 영원히 매칭되지 않습니다.

2) issuer 불일치로 인한 오탐

키 문제처럼 보이지만 사실은 iss가 다르게 들어온 토큰을 검증하고 있을 수 있습니다. 멀티 테넌트 또는 여러 IdP를 동시에 받는 구조라면 iss 기반으로 JWKS를 분기해야 합니다.

3) 캐시 만료 타이밍에 네트워크 문제가 겹침

EKS에서 DNS 불안정, NAT 이슈, egress 제한 등으로 JWKS fetch가 실패하면 검증 실패가 폭발합니다. 이 경우 애플리케이션 문제처럼 보여도 인프라가 원인일 수 있습니다. 네트워크 계층 점검이 필요하면 EKS에서 Pod DNS 실패 - CoreDNS·VPC CNI 점검도 같이 확인하세요.

4) 타임아웃이 너무 길어 요청 스레드가 잠김

JWKS fetch 타임아웃을 길게 잡으면, 캐시 갱신 구간에 요청 처리 자체가 지연되며 연쇄 장애로 이어질 수 있습니다. 특히 ALB/Ingress 타임아웃과 맞물리면 504가 늘어납니다. 관련해서는 EKS ALB Ingress에서 504 Idle timeout만 반복될 때도 참고할 만합니다.

권장 운영 정책 체크리스트

  • JWKS 캐시 TTL은 1분~10분 사이에서 시작하고, 키 회전 빈도/트래픽에 맞춰 조정
  • kid 미스 시 강제 리프레시 후 1회 재검증(무한 재시도 금지)
  • 캐시 갱신은 단일 플라이트로 묶어 폭주 방지
  • JWKS fetch 타임아웃을 짧게(예: 1~2초) 두고, 실패 시 관측 가능하게 메트릭/로그 추가
  • IdP에서 구 키 제거 시점은 “최대 토큰 TTL + 여유” 이후로 설정
  • 멀티 Pod 환경에서는 재시작/롤링 업데이트 시 JWKS warm-up(선조회) 고려

마무리

Node.js에서 JWT 검증 실패가 kid와 함께 나타난다면, 대부분은 “토큰이 잘못됐다”가 아니라 리소스 서버의 JWKS 캐시가 키 회전을 따라가지 못한 것입니다.

해결의 핵심은 단순히 TTL을 줄이는 것이 아니라, kid 미스 시 강제 리프레시와 단일 플라이트로 빠르게 회복하면서도 IdP를 때리지 않는 구조를 만드는 것입니다. 이를 로그/메트릭으로 관측 가능하게 만들면, 키 회전 이벤트가 있어도 401 폭증 없이 안정적으로 운영할 수 있습니다.