Published on

JWT 검증 실패? JWKS 키 롤오버·kid 오류 해결

Authors

서버에서 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 failed

    • kid는 찾았는데 서명이 맞지 않음
    • 잘못된 키를 선택했거나(동일 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가 일치하는 키로 서명을 검증합니다.

즉, 검증은 대략 아래 순서입니다.

  1. 토큰 헤더 파싱
  2. kid 확보
  3. JWKS에서 kid 매칭되는 JWK 선택
  4. JWK를 공개키로 변환
  5. alg allowlist 확인
  6. 서명 검증 + 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가지가 흔합니다.

  1. 애플리케이션 레벨에서 kid not found 시 강제 갱신(가장 간단)
  2. 공유 캐시(예: Redis)에 JWKS 캐시 저장
  3. 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. 실패한 토큰 1개 확보(로그/리퀘스트에서)
  2. 토큰 헤더의 kid, alg 확인
  3. 토큰 클레임의 iss, aud, exp 확인
  4. discovery 문서에서 jwks_uri 확인
  5. JWKS에서 kid 존재 여부 확인
  6. 서버의 JWKS 캐시 TTL/갱신 로직 확인(kid not found 시 강제 갱신 유무)
  7. 멀티 인스턴스면 특정 인스턴스에서만 실패하는지 확인(로드밸런서 로그)
  8. IdP 롤오버 이벤트/공지 확인
  9. 임시 완화: JWKS 캐시 무효화(재시작/캐시 flush) + TTL 단축
  10. 재발 방지: single-flight, 공유 캐시, 모니터링 추가

로그/모니터링 권장 사항

JWT 검증 실패를 “한 줄 에러”로만 남기면 원인 규명이 오래 걸립니다. 다음 필드는 개인정보/보안에 유의하면서 구조화 로그로 남기면 좋습니다.

  • kid, alg
  • iss(가능하면)
  • 실패 유형(키 미발견 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 기반)
  • alg allowlist 강제

이 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 문제를 빠르게 수습하고 재발을 줄일 수 있습니다.