Published on

Cloudflare JWT 검증 실패 - JWKS 캐시·kid 불일치 해결

Authors

서버리스나 엣지 레이어에서 인증을 붙일 때 Cloudflare를 앞단에 두고 JWT를 검증하는 구성이 흔합니다. 그런데 운영 중에 다음과 같은 증상이 간헐적으로 터집니다.

  • 어제까지 잘 되던 토큰이 갑자기 invalid signature 또는 kid not found로 실패
  • 특정 PoP(리전)에서만 실패하고 다른 리전에서는 통과
  • 키 롤오버 직후 5~30분 정도만 실패가 몰렸다가 자연 회복
  • 동일 토큰이 어떤 요청에서는 통과하고 어떤 요청에서는 실패

이 글은 Cloudflare에서 JWT 검증 실패가 발생할 때, 특히 JWKS 캐시 문제kid 불일치를 빠르게 진단하고 안정적으로 해결하는 방법을 다룹니다. JWT 서명검증 일반 원인도 함께 보려면 JWT invalid signature 서명검증 실패 원인 7가지를 먼저 읽어두면 맥락이 더 빨리 잡힙니다.

왜 Cloudflare에서 더 자주 터질까

Cloudflare는 전 세계 PoP에서 요청을 처리합니다. 따라서 다음 특성이 문제를 증폭시킵니다.

  • 분산 캐시: 어떤 PoP는 최신 JWKS를 갖고 있고, 어떤 PoP는 오래된 JWKS를 갖고 있을 수 있습니다.
  • 엣지 런타임 제약: Worker에서 외부 호출, 캐시 TTL, CPU 시간 제한 등으로 JWKS 갱신이 실패하면 그 PoP는 계속 낡은 키로 검증을 시도합니다.
  • 키 롤오버 타이밍: IdP(예: Auth0, Cognito, Firebase, Keycloak)가 키를 회전시키면 토큰의 kid가 바뀌고, JWKS도 바뀝니다. 이때 캐시가 따라오지 못하면 kid를 못 찾거나 서명검증이 실패합니다.

핵심은 “토큰은 새 키로 서명됐는데 검증기는 옛 키를 보고 있다” 혹은 “검증기가 찾는 kid가 JWKS에 없다”로 요약됩니다.

증상별로 원인 빠르게 매칭하기

1) kid not found 또는 Unable to find a signing key that matches kid

  • 토큰 헤더의 kid가 JWKS의 keys[].kid 중 어느 것과도 일치하지 않음
  • 보통 키 롤오버 직후 + JWKS 캐시가 오래됨 조합

2) invalid signature

  • kid는 찾았지만, 실제 서명검증이 실패
  • 가능한 원인
    • 잘못된 JWKS(다른 issuer의 JWKS를 참조)
    • 알고리즘 혼선(예: RS256인데 HS256로 검증)
    • 토큰이 다른 환경(스테이징)에서 발급됨
    • 키가 회전됐는데 캐시가 꼬여서 다른 키로 검증

3) 특정 리전에서만 실패

  • 해당 PoP의 캐시만 오래되었거나, JWKS 갱신 fetch가 네트워크/시간 제한으로 실패

진단 1단계: 토큰 헤더의 kid 확인

JWT는 header.payload.signature 구조입니다. 먼저 헤더를 디코드해 kid, alg를 확인합니다.

node -e "const t=process.argv[1]; const h=JSON.parse(Buffer.from(t.split('.')[0],'base64url').toString()); console.log(h);" "YOUR_JWT"

여기서 확인할 것:

  • kid: JWKS에서 찾을 키 식별자
  • alg: 보통 RS256 또는 ES256

진단 2단계: JWKS에 kid가 실제로 존재하는지 확인

JWKS URL은 일반적으로 다음 중 하나입니다.

  • OIDC discovery: https://issuer/.well-known/openid-configuration에서 jwks_uri 확인
  • 직접 JWKS: https://issuer/.well-known/jwks.json

JWKS를 받아서 kid 목록을 확인합니다.

curl -s "https://YOUR_ISSUER/.well-known/jwks.json" | jq -r '.keys[].kid'
  • 목록에 토큰의 kid가 없다면, 거의 확실히 캐시 문제 또는 issuer 혼선입니다.
  • 목록에 kid가 있는데도 Cloudflare에서만 실패한다면, Cloudflare 측 JWKS 캐시 또는 검증 코드의 키 선택 로직을 의심합니다.

Cloudflare에서 자주 발생하는 4가지 실전 원인

1) JWKS 캐시 TTL이 길거나 갱신 실패 시 계속 고정됨

Cloudflare Worker에서 JWKS를 KV/Cache API에 저장해두는 패턴은 흔합니다. 문제는 다음입니다.

  • TTL을 길게 잡으면 키 롤오버를 따라가지 못함
  • 갱신 fetch가 실패하면 “오래된 값”으로 계속 검증 시도

해결 원칙:

  • 짧은 TTL + 백그라운드 갱신(stale-while-revalidate)
  • 갱신 실패 시에도 즉시 장애로 만들지 말고, kid 미스가 났을 때 강제 리프레시

2) kid 불일치: issuer/환경 혼용

운영에서 자주 보는 케이스:

  • 프론트는 프로덕션 issuer로 로그인했는데, 백엔드는 스테이징 JWKS를 보고 검증
  • 멀티테넌트에서 테넌트별 issuer가 다른데, Cloudflare 설정은 하나로 고정

해결 원칙:

  • 토큰의 iss를 읽고, 그 iss에 매핑되는 jwks_uri로 검증
  • iss allowlist를 강제하고, 임의 issuer를 허용하지 않기

3) PoP별 캐시 편차

Cloudflare는 전 세계 엣지에 캐시가 퍼집니다. 특정 PoP에서만 실패한다면 다음이 원인일 수 있습니다.

  • 해당 PoP에서 JWKS fetch가 일시 실패
  • 그 PoP의 캐시만 갱신이 늦음

해결 원칙:

  • 캐시 키에 issuer를 포함
  • kid 미스 시 강제 리프레시 후 재시도
  • 관측 가능성(로그)에 colo(Cloudflare colo 코드), issuer, kid, JWKS age 등을 남김

4) 잘못된 키 선택 로직(첫 번째 키로 검증)

JWKS에는 여러 키가 들어있습니다(롤오버 기간엔 특히). 그런데 구현이 단순해서 keys[0]으로만 검증하면, kid가 다른 토큰은 무조건 실패합니다.

해결 원칙:

  • 무조건 kid로 키를 찾아 검증
  • kid가 없다면(드물지만 존재) 정책적으로 거부하거나, 안전한 범위에서만 fallback

Worker에서 안전한 JWKS 캐시 구현 패턴

아래 예시는 Cloudflare Worker에서 JWKS를 캐시하면서도 키 롤오버에 강한 패턴입니다.

요구사항:

  • JWKS는 캐시하되 TTL을 짧게
  • kid가 없으면 즉시 리프레시
  • 리프레시 후에도 없으면 401
  • iss allowlist로 issuer 고정

주의: 본문에서 부등호 기호는 MDX 빌드 오류를 피하기 위해 사용하지 않으며, 타입 제네릭 표기도 인라인 코드로 감쌉니다.

// Cloudflare Worker (modules)
import { jwtVerify, createRemoteJWKSet } from 'jose'

const ISSUER_ALLOWLIST = new Set([
  'https://your-issuer.example.com/',
])

// jose의 RemoteJWKSet은 내부적으로 fetch + 캐시를 수행하지만,
// 엣지 환경에서 정책을 더 강하게 가져가려면 kid 미스 시 재생성 전략을 섞을 수 있습니다.

function getJwksUrlForIssuer(iss) {
  // 실무에서는 OIDC discovery를 캐시해서 jwks_uri를 가져오기도 합니다.
  // 여기서는 단순화합니다.
  return new URL('/.well-known/jwks.json', iss).toString()
}

// issuer 별로 RemoteJWKSet을 메모리에 캐시
const jwksByIssuer = new Map()

function getRemoteJwks(iss) {
  const jwksUrl = getJwksUrlForIssuer(iss)
  const cached = jwksByIssuer.get(jwksUrl)
  if (cached) return cached

  const remote = createRemoteJWKSet(new URL(jwksUrl), {
    // 최신 jose에서는 쿨다운/타임아웃 옵션을 지원합니다.
    // 버전에 따라 옵션명이 다를 수 있으니 문서를 확인하세요.
  })

  jwksByIssuer.set(jwksUrl, remote)
  return remote
}

async function verifyJwtOrThrow(token) {
  // 1) 헤더/클레임을 먼저 파싱해 iss를 얻고 allowlist 검사
  const parts = token.split('.')
  if (parts.length !== 3) throw new Error('malformed token')

  const payload = JSON.parse(
    Buffer.from(parts[1], 'base64url').toString('utf8')
  )

  const iss = payload.iss
  if (!iss || !ISSUER_ALLOWLIST.has(iss)) {
    throw new Error('invalid issuer')
  }

  // 2) 표준 검증
  const jwks = getRemoteJwks(iss)

  try {
    return await jwtVerify(token, jwks, {
      issuer: iss,
      // audience는 반드시 서비스에 맞게 고정하세요.
      // audience: 'your-audience',
    })
  } catch (e) {
    // 3) kid 미스/키 롤오버 의심 시: RemoteJWKSet을 재생성하여 강제 리프레시 유도
    // jose는 상황에 따라 JWKS 재조회 로직이 있지만,
    // 엣지에서 "확실히" 새로 받아오게 하려면 재생성이 간단한 해법입니다.

    const msg = String(e && e.message ? e.message : e)
    const looksLikeKidIssue =
      msg.includes('no applicable key') ||
      msg.includes('kid') ||
      msg.includes('JWKS')

    if (!looksLikeKidIssue) throw e

    const jwksUrl = getJwksUrlForIssuer(iss)
    jwksByIssuer.delete(jwksUrl)

    const fresh = getRemoteJwks(iss)
    return await jwtVerify(token, fresh, { issuer: iss })
  }
}

export default {
  async fetch(request, env, ctx) {
    const auth = request.headers.get('authorization') || ''
    const token = auth.startsWith('Bearer ') ? auth.slice(7) : null

    if (!token) return new Response('Unauthorized', { status: 401 })

    try {
      const { payload, protectedHeader } = await verifyJwtOrThrow(token)

      // 관측 가능성: colo, kid, iss를 로그로 남기면 PoP 편차 진단이 쉬워집니다.
      // console.log({ colo: request.cf?.colo, kid: protectedHeader.kid, iss: payload.iss })

      return new Response('OK', { status: 200 })
    } catch (e) {
      return new Response('Unauthorized', { status: 401 })
    }
  },
}

이 패턴의 포인트

  • iss를 먼저 확인하고 allowlist로 고정해 환경 혼용을 차단합니다.
  • 첫 검증 실패가 kid 계열로 보이면 JWKS 핸들을 재생성해 강제 재조회를 유도합니다.
  • PoP별 캐시 편차가 있더라도, 해당 PoP에서 실패한 요청이 들어오면 즉시 새 JWKS로 재시도합니다.

Cloudflare Cache API로 직접 JWKS 캐시할 때의 권장 정책

직접 caches.default에 JWKS JSON을 넣는 경우라면 다음 정책이 안전합니다.

  • TTL: 300초 내외(키 롤오버가 잦거나 장애 비용이 크면 더 짧게)
  • stale-while-revalidate: 가능하면 적용
  • kid not found 발생 시: 캐시 삭제 후 즉시 재조회
  • 캐시 키: jwks:${issuer} 형태로 issuer 포함

간단한 예시입니다.

async function fetchJwksWithCache(jwksUrl) {
  const cache = caches.default
  const cacheKey = new Request(jwksUrl, { method: 'GET' })

  let res = await cache.match(cacheKey)
  if (!res) {
    res = await fetch(jwksUrl, {
      headers: { 'accept': 'application/json' },
      cf: { cacheTtl: 300, cacheEverything: true },
    })

    // 응답이 정상일 때만 캐시
    if (res.ok) {
      await cache.put(cacheKey, res.clone())
    }
  }

  if (!res.ok) throw new Error('jwks fetch failed')
  return res.json()
}

async function purgeJwksCache(jwksUrl) {
  const cache = caches.default
  const cacheKey = new Request(jwksUrl, { method: 'GET' })
  await cache.delete(cacheKey)
}

이 방식은 단순하지만, cacheEverything은 의도치 않은 캐시를 유발할 수 있으니 JWKS URL에만 제한해 사용하세요.

운영에서 재현하는 체크리스트

장애가 “간헐적”일수록 재현이 어렵습니다. 아래 항목을 로그에 남겨두면 원인 규명이 빨라집니다.

  • request.cf.colo: 실패가 특정 PoP에 집중되는지
  • 토큰 헤더 kid, alg
  • 토큰 클레임 iss, aud
  • 현재 사용 중인 JWKS URL
  • JWKS 캐시 age(직접 캐시 구현 시)
  • 실패 에러 메시지 원문(가능하면)

그리고 키 롤오버 직후에만 터진다면 IdP 쪽 설정도 봐야 합니다.

  • 새 키 배포와 동시에 이전 키를 너무 빨리 제거하지는 않는지
  • 토큰 TTL이 긴데 이전 키를 빨리 내리면, 아직 유효한 토큰이 검증 불가가 됩니다

자주 하는 실수와 방지책

aud 검증을 빼고 서명만 검증

서명만 맞으면 다른 클라이언트용 토큰도 통과할 수 있습니다. Cloudflare에서 엣지 인증을 한다면 aud는 거의 필수입니다.

OIDC discovery를 매 요청마다 호출

/.well-known/openid-configuration을 매번 fetch하면 지연과 실패 확률이 올라갑니다. discovery는 캐시하고, jwks_uri만 주기적으로 갱신하세요.

멀티 issuer를 허용하면서 allowlist가 없음

iss를 신뢰하지 않으면 임의 issuer의 토큰을 받아들이는 꼴이 될 수 있습니다. 반드시 allowlist를 두고, 테넌트별로 분기해야 한다면 매핑 테이블을 명시적으로 관리하세요.

장애가 계속되면: 최종 점검 순서

  1. 토큰 헤더에서 kid 확인
  2. 토큰 클레임에서 iss 확인
  3. iss에 대응하는 JWKS에서 kid 존재 여부 확인
  4. Cloudflare에서만 실패하면 PoP별 캐시 편차 의심
  5. kid not found면 캐시 purge 후 재시도 로직 추가
  6. 키 롤오버 정책(이전 키 유지 기간) 점검

JWT 서명검증 실패 자체를 더 넓게 점검할 때는 JWT invalid signature 서명검증 실패 원인 7가지도 함께 참고하면, kid/JWKS 외의 원인(알고리즘 혼선, issuer/audience 불일치, 공개키 포맷 문제 등)까지 한 번에 체크할 수 있습니다.

마무리

Cloudflare에서 JWT 검증이 간헐적으로 실패하는 문제는 대부분 키 롤오버와 분산 캐시의 타이밍 문제로 귀결됩니다. 해결의 핵심은 다음 3가지입니다.

  • iss를 고정(allowlist)하고 JWKS URL을 정확히 매핑하기
  • JWKS 캐시를 짧게 가져가되, kid 미스 시 강제 리프레시로 즉시 복구하기
  • PoP/캐시 age/kid/iss를 로그로 남겨 “어디서” 실패하는지 보이게 만들기

이 3가지만 갖추면, 키 회전이 있는 환경에서도 인증 장애를 “간헐적 미스터리”가 아니라 “예측 가능한 이벤트”로 관리할 수 있습니다.