Published on

JWT kid 없음·불일치로 401? JWKS 회전 대응

Authors

서버에서 JWT를 검증하다가 갑자기 401이 폭증하고 로그에 kid missing, kid not found, unable to find a signing key that matches kid 같은 메시지가 보이면, 대부분 원인은 키 식별자 kid 처리JWKS(Key Set) 회전의 타이밍 불일치입니다. 특히 IdP(예: Cognito, Auth0, Okta, Keycloak)가 키를 교체하는 순간, 애플리케이션이 이전 JWKS를 캐시한 채로 새 토큰을 검증하려다 실패하는 패턴이 자주 발생합니다.

이 글은 다음을 목표로 합니다.

  • kid가 왜 필요한지, 왜 없거나 불일치하면 401이 나는지
  • JWKS 회전 시 어떤 레이스 컨디션이 생기는지
  • 운영에서 안전한 캐싱, 재시도, 관측(로그/메트릭) 전략
  • Node.js 예제로 구현 포인트까지

관련 장애 대응 관점은 아래 글도 함께 참고하면 좋습니다.


kid는 무엇이고, 왜 없으면 위험한가

JWT는 보통 JWS(서명된 토큰) 형태이고, 헤더에 다음 정보가 들어갑니다.

  • alg: 서명 알고리즘(RS256, ES256 등)
  • typ: 보통 JWT
  • kid: 어떤 공개키로 검증해야 하는지 식별하는 키 ID

IdP는 여러 개의 공개키를 JWKS 엔드포인트에 노출합니다. 검증자는 토큰 헤더의 kid를 보고 JWKS에서 동일한 kid를 가진 공개키를 찾아 서명을 검증합니다.

kid가 없을 때 흔한 실패 모드

  1. 검증 라이브러리가 키 선택을 못함
    • JWKS에 키가 여러 개면 어느 키를 써야 하는지 결정 불가
  2. 첫 번째 키로 시도했다가 실패
    • 일부 구현은 "첫 키로 검증" 같은 비결정적 동작을 하기도 하는데, 이는 장애/보안 측면에서 최악입니다.

즉, kid 누락은 단순한 "필드 하나 없음"이 아니라, 키 선택 로직이 붕괴하는 문제입니다.


kid 불일치로 401이 나는 대표 시나리오

1) IdP가 키를 회전했는데, 애플리케이션이 JWKS를 오래 캐시함

  • IdP는 새 키로 토큰을 발급
  • 애플리케이션은 캐시된 JWKS(구 키만 포함)로 검증
  • 결과: kid not found로 401

이때 장애는 "특정 시점부터" 또는 "특정 Pod/인스턴스에서만" 발생하는 양상을 보입니다. (인스턴스별 캐시가 다르기 때문)

2) CDN/프록시 캐시가 JWKS를 오래 잡고 있음

JWKS 엔드포인트 앞단에 캐시 레이어가 있거나, 기업망 프록시가 응답을 캐시하는 경우가 있습니다. Cache-Control이 보수적으로 설정되어 있으면 회전 직후 구 JWKS가 계속 내려오면서 검증이 실패합니다.

3) 토큰이 잘못된 issuer의 JWKS로 검증되고 있음

멀티 테넌트/멀티 환경에서 흔합니다.

  • 토큰의 iss는 A
  • 서버 설정은 B의 JWKS를 바라봄
  • kid는 당연히 매칭되지 않음

이 경우는 JWKS 회전 문제가 아니라 설정 문제지만, 증상은 동일하게 kid not found로 나타납니다.

4) 알고리즘/키 타입 혼선

예를 들어 algES256인데 JWKS에는 RSA 키만 있다든지, 혹은 검증 라이브러리가 x5c 체인을 기대하는데 n/e만 있는 RSA JWK를 못 읽는 경우도 있습니다. 다만 최근 라이브러리는 대부분 잘 처리합니다.


진단 체크리스트: 401이 kid 때문인지 빠르게 확인하기

운영에서 가장 먼저 확인할 것은 "토큰 헤더"와 "현재 JWKS"입니다.

1) 토큰 헤더에서 kid, alg 확인

JWT는 .으로 3분할된 Base64URL 문자열입니다. 헤더만 확인하려면 검증 없이 디코딩하면 됩니다.

node -e "const t=process.argv[1]; const h=JSON.parse(Buffer.from(t.split('.')[0].replace(/-/g,'+').replace(/_/g,'/'),'base64').toString()); console.log(h);" "${JWT}"

출력에서 kid가 없거나, alg가 예상과 다르면 바로 원인 후보가 좁혀집니다.

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

curl -s "${JWKS_URL}" | jq '.keys[] | {kid, kty, alg, use}'
  • 토큰의 kid가 JWKS에 없다면 회전/캐시/issuer 불일치 중 하나입니다.

3) issaud가 서버 설정과 일치하는지 확인

토큰 payload의 iss, aud를 확인합니다.

node -e "const t=process.argv[1]; const p=JSON.parse(Buffer.from(t.split('.')[1].replace(/-/g,'+').replace(/_/g,'/'),'base64').toString()); console.log({iss:p.iss,aud:p.aud,exp:p.exp});" "${JWT}"

JWKS 회전 대응의 핵심: "캐시는 하되, kid not found면 즉시 새로고침"

운영에서 가장 안전하고 널리 쓰이는 전략은 다음 2단계입니다.

  1. 정상 경로에서는 JWKS를 캐시
    • 매 요청마다 JWKS를 가져오면 지연/비용/장애 전파가 커짐
  2. 예외 경로에서만 강제 리프레시
    • 검증 실패 원인이 kid not found일 때만 JWKS를 즉시 다시 가져와 재검증

이 패턴은 회전 시점의 레이스를 흡수하면서도, 평시 성능을 유지합니다.

캐시 TTL은 어떻게 잡아야 하나

  • 이상적으로는 JWKS 응답의 Cache-Control: max-age를 존중
  • IdP가 이를 제공하지 않거나 신뢰하기 어렵다면, 보수적으로 5m 내외를 많이 씁니다.
  • 단, TTL을 줄인다고 회전 문제가 0이 되지는 않습니다. 회전이 TTL 사이에 발생하면 여전히 틈이 생기므로, kid not found 시 강제 리프레시 로직이 필수입니다.

Node.js 예제: kid not found면 JWKS 강제 갱신 후 재시도

아래 예제는 jose 기반으로 JWKS를 가져와 검증하는 흐름을 단순화해 보여줍니다. 포인트는 다음입니다.

  • JWKS를 메모리에 캐시
  • kid 매칭 실패 시 JWKS를 즉시 다시 로드
  • 동시성 폭주를 막기 위해 "리프레시 중이면 같은 Promise를 공유"(single-flight)
import { jwtVerify, importJWK } from 'jose'

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

let jwksCache = null
let jwksFetchedAt = 0
let refreshInFlight = null

const TTL_MS = 5 * 60 * 1000

async function fetchJwks() {
  const res = await fetch(JWKS_URL, { headers: { 'accept': 'application/json' } })
  if (!res.ok) throw new Error(`jwks fetch failed: ${res.status}`)
  const data = await res.json()
  if (!data || !Array.isArray(data.keys)) throw new Error('invalid jwks payload')
  return data
}

async function getJwks({ force = false } = {}) {
  const now = Date.now()
  const fresh = jwksCache && (now - jwksFetchedAt) < TTL_MS
  if (!force && fresh) return jwksCache

  if (refreshInFlight) return refreshInFlight

  refreshInFlight = (async () => {
    try {
      const jwks = await fetchJwks()
      jwksCache = jwks
      jwksFetchedAt = Date.now()
      return jwks
    } finally {
      refreshInFlight = null
    }
  })()

  return refreshInFlight
}

async function findKeyByKid(jwks, kid) {
  const jwk = jwks.keys.find(k => k.kid === kid)
  if (!jwk) return null
  return importJWK(jwk, jwk.alg)
}

function parseJwtHeader(token) {
  const [h] = token.split('.')
  const json = Buffer.from(h.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')
  return JSON.parse(json)
}

export async function verifyAccessToken(token) {
  const header = parseJwtHeader(token)
  const kid = header.kid
  if (!kid) {
    const err = new Error('jwt header missing kid')
    err.code = 'KID_MISSING'
    throw err
  }

  // 1차: 캐시된 JWKS로 시도
  let jwks = await getJwks({ force: false })
  let key = await findKeyByKid(jwks, kid)

  // 핵심: kid 매칭 실패면 강제 리프레시 후 1회 재시도
  if (!key) {
    jwks = await getJwks({ force: true })
    key = await findKeyByKid(jwks, kid)
  }

  if (!key) {
    const err = new Error(`kid not found in jwks: ${kid}`)
    err.code = 'KID_NOT_FOUND'
    throw err
  }

  const { payload } = await jwtVerify(token, key, {
    issuer: ISSUER,
    audience: AUDIENCE,
  })

  return payload
}

이 구현에서 주의할 점

  • kid가 없으면 즉시 실패시키는 것이 안전합니다. (다중 키 환경에서는 추측 검증 금지)
  • kid not found로 리프레시를 걸더라도 재시도는 1회로 제한하세요. 무한 재시도는 장애를 증폭시킵니다.
  • JWKS fetch 실패(네트워크, DNS, TLS)와 kid not found는 다른 문제입니다. 로그/메트릭에서 반드시 분리해야 합니다.

운영 설계: 401 폭증을 막는 관측과 방어

1) 에러를 "401" 하나로 뭉개지 말고 원인별로 태깅

다음처럼 애플리케이션 로그에 원인 코드를 남기면, 401이 늘었을 때 "회전"인지 "설정"인지 "네트워크"인지 즉시 구분됩니다.

  • KID_MISSING
  • KID_NOT_FOUND
  • JWKS_FETCH_FAILED
  • ISSUER_MISMATCH
  • AUDIENCE_MISMATCH
  • TOKEN_EXPIRED

특히 KID_NOT_FOUND 비율이 갑자기 올라가면, JWKS 회전/캐시/issuer 불일치 가능성이 큽니다.

2) 메트릭 권장

  • jwks_fetch_total{status}
  • jwks_cache_age_ms
  • jwt_verify_fail_total{reason}
  • jwt_kid_not_found_total

이 정도만 있어도 회전 이벤트를 "관측 가능한 이벤트"로 바꿀 수 있습니다.

3) 프리페치(사전 로드)로 회전 순간을 완화

프로세스 시작 시 또는 주기적으로 JWKS를 미리 가져오면, 첫 요청이 JWKS fetch 지연을 떠안지 않습니다.

  • 애플리케이션 부팅 시 getJwks({ force: true }) 1회
  • 이후 주기적 갱신(예: 10분마다) + 요청 경로는 캐시 사용

단, 주기 갱신만으로는 레이스를 완전히 제거할 수 없으므로 kid not found 시 강제 리프레시는 여전히 필요합니다.


자주 하는 실수 5가지

1) kid가 없으면 "모든 키로 검증" 시도

키 개수가 적으면 동작은 할 수 있지만, 성능 낭비이고 무엇보다 의도치 않은 키로 통과할 가능성을 만들 수 있습니다. kid 없는 토큰은 거부하는 편이 일반적으로 안전합니다.

2) JWKS를 매 요청마다 가져오기

IdP 장애가 곧바로 내 서비스 장애로 전이됩니다. 또한 레이턴시가 커지고, 트래픽이 많으면 IdP rate limit에 걸립니다.

3) 캐시 TTL을 길게 잡고 예외 처리 없음

회전 직후 대규모 401이 발생합니다. "TTL을 길게" 자체가 문제라기보다, kid not found 시 강제 갱신이 없는 것이 문제입니다.

4) issuer/audience 검증을 끄고 "서명만" 검증

kid 문제를 우회하려고 검증 옵션을 느슨하게 하면 보안 사고로 이어질 수 있습니다. iss, aud, exp는 반드시 검증하세요.

5) 멀티 리전/멀티 인스턴스에서 캐시 동기화가 된다고 가정

각 인스턴스는 독립적으로 캐시합니다. 회전 순간에 일부 인스턴스만 401을 뿜는 현상은 자연스러운 결과입니다. 인스턴스 간 중앙 캐시를 쓸 수도 있지만, 그 캐시가 또 SPOF가 되지 않도록 설계해야 합니다.


실전 결론: 401을 "회전 이벤트"로 흡수하는 패턴

  • kid는 다중 키 환경에서 필수이며, 누락 토큰은 원칙적으로 거부
  • JWKS는 캐시하되, kid not found일 때만 강제 리프레시 후 1회 재검증
  • iss/aud 불일치와 회전 문제를 로그/메트릭으로 분리
  • 프리페치 + single-flight로 성능과 안정성을 함께 확보

이 패턴을 적용하면, IdP가 키를 회전하더라도 401 폭증을 "짧은 스파이크" 수준으로 줄이거나, 아예 사용자 체감 없이 흡수하는 것도 가능합니다.