- Published on
Cloudflare 뒤단 JWT 검증 실패 - JWKS 캐시·kid 키회전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 Cloudflare 뒤에 있고, 액세스 토큰은 정상처럼 보이는데도 백엔드에서 간헐적으로 JWT 검증이 실패하는 경우가 있습니다. 로그에는 보통 kid not found, unable to find a signing key, signature verification failed 같은 메시지가 남습니다. 이 문제는 네트워크나 프록시 설정처럼 보이지만, 실제로는 JWKS 캐시가 키 회전을 따라가지 못하는 상황이 가장 흔합니다.
이번 글에서는 kid 기반 키 선택 흐름, JWKS 캐시가 깨지는 대표 패턴, 그리고 운영 환경에서 안전하게 해결하는 방법(강제 리프레시, 캐시 TTL, 동시성 제어, 장애 시 폴백)을 정리합니다.
증상: Cloudflare 뒤단에서만 터지는 것처럼 보이는 이유
Cloudflare 자체가 JWT를 검증하는 구성(예: Cloudflare Access, Workers에서 검증, WAF 룰 등)이 아니라면, Cloudflare는 보통 그냥 프록시입니다. 그런데도 “Cloudflare 뒤에서만” 문제가 보이는 이유는 다음과 같습니다.
- 트래픽이 늘면서 백엔드 인스턴스 수가 증가하고, 각 인스턴스의 JWKS 캐시 상태가 제각각이 됨
- 토큰 발급자(IdP)가 키를 회전했는데, 일부 인스턴스는 이전 JWKS를 오래 들고 있음
- 캐시 미스 시 JWKS 엔드포인트로 동시 요청이 폭증하면서, 일부 요청이 타임아웃 또는 레이트리밋으로 실패
- Cloudflare 캐시(또는 egress 경로) 자체보다, 백엔드의 캐시/동시성/리트라이 설계가 병목이 됨
즉, Cloudflare는 “원인”이라기보다 **문제가 드러나는 환경(트래픽/확장/분산)**을 만들어 주는 경우가 많습니다.
JWT 검증에서 kid 와 JWKS의 관계
JWT 헤더에는 보통 다음 값이 있습니다.
alg: 서명 알고리즘(예:RS256)kid: 어떤 공개키로 검증해야 하는지 식별하는 키 ID
검증기는 다음 순서로 동작합니다.
- JWT 헤더에서
kid를 읽음 - JWKS(JSON Web Key Set)에서
kid가 일치하는 JWK를 찾음 - JWK에서 공개키를 구성해 서명을 검증
문제는 2번입니다. JWKS를 캐시해두면 성능은 좋아지지만, 키 회전 시점에는 캐시가 최신이 아닐 수 있습니다.
가장 흔한 실패 시나리오 3가지
1) kid not found: 키 회전 직후 캐시가 구버전
- IdP가 새 키를 추가하고, 새
kid로 토큰을 발급 - 백엔드는 여전히 이전 JWKS를 캐시 중
- 결과:
kid not found
이 케이스는 “서명 검증 실패”가 아니라, 검증할 키를 찾지 못한 것입니다.
2) signature verification failed: 같은 kid인데 키가 바뀌었거나 잘못 매핑
정상적인 키 회전은 보통 kid가 바뀝니다. 하지만 다음 상황이 섞이면 서명 불일치가 납니다.
- 발급자 측 설정 오류로
kid재사용 - JWKS 엔드포인트에서 잘못된 키셋을 반환(테넌트/환경 혼선)
- 멀티 리전에서 배포된 JWKS가 전파 지연으로 서로 다른 값을 반환
3) JWKS 엔드포인트 장애/레이트리밋: 캐시 갱신이 실패
캐시 TTL이 짧거나, 키 회전 직후 kid not found가 대량 발생하면 백엔드는 JWKS를 재조회하려고 합니다. 이때 동시성 제어가 없으면:
- 모든 요청이 JWKS로 몰림(Thundering Herd)
- 네트워크 타임아웃, 429, 5xx
- 결국 검증 실패가 더 늘어남
이 패턴은 API 재시도 설계와도 유사합니다. 레이트리밋 상황에서 어떻게 백오프를 설계할지 감이 필요하다면 OpenAI 429와 Rate Limit 헤더로 재시도 설계도 함께 참고하면 좋습니다.
해결 전략: “캐시를 하되, kid 미스는 즉시 리프레시”
핵심은 단순합니다.
- JWKS는 캐시한다
- 하지만 JWT 헤더의
kid가 캐시에 없으면 JWKS를 즉시 새로고침한다 - 새로고침 후에도 없으면 그때 401/403 처리한다
여기에 운영에서 중요한 디테일 4가지를 더해야 합니다.
- 캐시 TTL을 너무 길게 잡지 않는다(예: 5분~1시간 사이, IdP 정책에 맞춤)
- 동시성 제어(락/싱글플라이트)로 JWKS 재조회 폭주를 막는다
- JWKS 조회 실패 시 “마지막으로 성공한 키셋”을 일정 시간 더 유지하는 폴백을 둔다
- 관측 가능성(로그/메트릭)으로
kid not found와 JWKS fetch 실패를 분리해 본다
Node.js 예시: jose로 JWKS 캐시 + kid 미스 시 강제 갱신
아래 예시는 jose를 사용해 JWKS를 가져오고, 기본 캐시를 활용하면서도 kid 미스 상황에서 재시도를 설계하는 패턴입니다.
주의: 코드/문서에서 부등호 문자가 보이면 MDX에서 JSX로 오인될 수 있으니, 본문에서는 < > 또는 백틱으로 처리합니다.
import { createRemoteJWKSet, jwtVerify, errors } from 'jose'
const JWKS_URL = new URL('https://issuer.example.com/.well-known/jwks.json')
// jose의 RemoteJWKSet은 내부적으로 캐시/쿨다운을 가짐
// 다만 운영 요구에 맞춰 kid miss 시 리프레시 전략을 추가로 얹는 게 안전함
const jwks = createRemoteJWKSet(JWKS_URL)
type VerifyOptions = {
issuer: string
audience: string
}
export async function verifyAccessToken(token: string, opt: VerifyOptions) {
try {
const result = await jwtVerify(token, jwks, {
issuer: opt.issuer,
audience: opt.audience,
})
return result.payload
} catch (e) {
// kid not found 류 에러는 라이브러리마다 타입/메시지가 다름
// 운영에서는 메시지 매칭보다는 에러 타입을 우선
if (e instanceof errors.JWKSNoMatchingKey) {
// 1) JWKS를 다시 가져오도록 유도
// jose는 내부적으로 쿨다운이 있어 무한 재조회는 안 되지만,
// 여기서는 한 번 더 검증을 시도하는 패턴을 사용
const retry = await jwtVerify(token, jwks, {
issuer: opt.issuer,
audience: opt.audience,
})
return retry.payload
}
throw e
}
}
위 코드만으로도 “키 회전 직후 kid 미스”는 상당 부분 완화됩니다. 다만 대규모 트래픽에서는 동시성 제어가 더 중요합니다.
동시성 제어: 싱글플라이트로 JWKS 재조회 폭주 막기
kid not found가 발생하면 여러 요청이 동시에 JWKS를 갱신하려고 하면서 외부 IdP에 부하를 줄 수 있습니다. 아래는 간단한 싱글플라이트 패턴입니다.
let jwksRefreshPromise: Promise<void> | null = null
async function refreshJwksOnce(): Promise<void> {
if (jwksRefreshPromise) return jwksRefreshPromise
jwksRefreshPromise = (async () => {
try {
// createRemoteJWKSet은 내부적으로 fetch를 수행하며 캐시를 갱신
// 여기서는 "강제 갱신" API가 따로 없으므로,
// kid miss가 난 토큰을 재검증하는 방식으로 갱신을 유도하거나,
// 라이브러리/구현에 따라 별도 JWKS fetch 로직을 둘 수 있음
await new Promise((r) => setTimeout(r, 50))
} finally {
jwksRefreshPromise = null
}
})()
return jwksRefreshPromise
}
export async function verifyWithSingleflight(token: string, opt: { issuer: string; audience: string }) {
try {
const result = await jwtVerify(token, jwks, opt)
return result.payload
} catch (e) {
if (e instanceof errors.JWKSNoMatchingKey) {
await refreshJwksOnce()
const retry = await jwtVerify(token, jwks, opt)
return retry.payload
}
throw e
}
}
실전에서는 refreshJwksOnce 내부에서 직접 JWKS를 fetch하고, 캐시 스토어(메모리/Redis)에 저장하는 방식을 쓰기도 합니다. 특히 멀티 인스턴스라면 “인스턴스별 메모리 캐시”만으로는 한계가 있어 Redis 같은 공유 캐시가 유리합니다.
공유 캐시(Redis)로 인스턴스 간 JWKS 일관성 맞추기
인스턴스가 여러 개면, 한 인스턴스는 최신 JWKS를 갖고 다른 인스턴스는 구버전을 갖는 시간이 생깁니다. 이를 줄이려면 JWKS를 Redis에 저장하고 다음을 구현합니다.
jwks:json키에 JWKS 원문 저장jwks:etag또는jwks:lastFetchedAt저장- TTL 부여(예: 10분)
kid not found발생 시 Redis TTL을 무시하고 즉시 갱신(락 사용)
간단 예시(개념 코드):
import Redis from 'ioredis'
const redis = new Redis(process.env.REDIS_URL!)
const JWKS_CACHE_KEY = 'auth:jwks:json'
const JWKS_LOCK_KEY = 'auth:jwks:lock'
async function fetchJwks(): Promise<any> {
const res = await fetch('https://issuer.example.com/.well-known/jwks.json', {
headers: { 'accept': 'application/json' },
})
if (!res.ok) throw new Error(`jwks fetch failed: ${res.status}`)
return await res.json()
}
async function getJwksCached(): Promise<any> {
const cached = await redis.get(JWKS_CACHE_KEY)
if (cached) return JSON.parse(cached)
const jwks = await fetchJwks()
await redis.set(JWKS_CACHE_KEY, JSON.stringify(jwks), 'EX', 600)
return jwks
}
async function refreshJwksWithLock(): Promise<any> {
// SET NX로 간단 락
const locked = await redis.set(JWKS_LOCK_KEY, '1', 'NX', 'EX', 5)
if (!locked) {
// 누군가 갱신 중이면 캐시를 다시 읽어봄
await new Promise((r) => setTimeout(r, 100))
return await getJwksCached()
}
try {
const jwks = await fetchJwks()
await redis.set(JWKS_CACHE_KEY, JSON.stringify(jwks), 'EX', 600)
return jwks
} finally {
await redis.del(JWKS_LOCK_KEY)
}
}
이 방식은 “키 회전 직후”에도 전체 인스턴스가 빠르게 수렴합니다.
Cloudflare 환경에서 추가로 확인할 포인트
1) Authorization 헤더가 원본까지 전달되는지
Cloudflare 설정이나 Workers/Transform Rules에 의해 Authorization 헤더가 제거/변경되면 JWT 검증이 실패합니다. 이 경우는 kid 이슈가 아니라 토큰 자체가 없거나 변형된 형태로 들어옵니다.
- 원본 서버에서 요청 헤더 덤프 로그를 한시적으로 남겨 확인
- Cloudflare Workers를 쓴다면
request.headers.get('Authorization')확인
2) 시간 동기화(NTP)와 exp nbf 오차
간헐적 실패가 token expired 또는 not active yet라면 키 회전이 아니라 서버 시간 오차일 수 있습니다. 컨테이너/VM의 NTP 상태를 점검하세요.
3) 멀티 테넌트/멀티 환경에서 issuer 혼선
스테이징과 프로덕션이 다른 issuer를 쓰는데, 백엔드가 잘못된 JWKS URL을 보거나, 토큰의 iss와 검증 설정의 issuer가 불일치하면 실패합니다.
OAuth 플로우 설정 문제로 토큰 발급 자체가 꼬이는 경우도 자주 있으니, 인증 서버 쪽 400 이슈가 있었다면 Auth0 OAuth 400 invalid_grant - PKCE·redirect_uri 해결도 함께 점검해볼 만합니다.
운영 체크리스트: 재발 방지용
kid not found발생률을 메트릭으로 분리(서명 불일치와 구분)- JWKS fetch 성공/실패, 응답 시간, 429/5xx 비율을 메트릭화
- 캐시 TTL은 IdP 키 회전 정책과 정합(너무 길면 회전 대응이 늦고, 너무 짧으면 외부 의존도가 커짐)
kid미스 시 1회 리프레시 후 재검증(무한 재시도 금지)- 동시성 제어(싱글플라이트 또는 분산 락)
- JWKS fetch 실패 시 “마지막 성공 캐시”를 일정 시간 유지하는 폴백
- 장애 시나리오 게임데이: IdP JWKS 엔드포인트 5xx/429를 가정하고 시스템이 어떻게 동작하는지 확인
캐시가 원인인 문제는 인증뿐 아니라 프론트엔드 ISR에서도 비슷한 형태로 터집니다. 캐시가 갱신되지 않아 예상과 다른 결과가 나오는 디버깅 관점은 Next.js 14 캐시 때문에 ISR 갱신 안 될 때 디버깅도 사고방식에 도움이 됩니다.
결론
Cloudflare 뒤단에서 발생하는 JWT 검증 실패는 대개 프록시 자체 문제가 아니라, JWKS 캐시와 kid 기반 키 회전 타이밍에서 비롯됩니다. 해결의 핵심은 “캐시를 믿되, kid 미스는 즉시 리프레시”이며, 트래픽이 커질수록 동시성 제어와 공유 캐시가 중요해집니다.
로그에서 kid not found와 signature verification failed를 분리해 관측하고, JWKS 갱신 경로를 단단하게 만들면 키 회전 이벤트도 무중단으로 흡수할 수 있습니다.