- Published on
JWT kid 불일치로 401? JWKS 캐시 버그 추적
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 JWT 검증을 붙여두면, 평소엔 조용하다가 갑자기 일부 요청만 401 Unauthorized가 터지는 날이 있습니다. 로그를 보면 대개 이런 메시지가 같이 따라옵니다.
kid not foundUnable to find a signing key that matches 'kid'JWKS has no key for kid=...
겉으로는 “토큰이 이상한가?” 싶지만, 실제로는 키 롤오버(회전) + JWKS 캐시 전략이 맞물리며 발생하는 경우가 많습니다. 특히 멀티 인스턴스/서버리스/쿠버네티스 환경에서 동시성·캐시 갱신 레이스가 있으면 재현이 어려운 ‘간헐적 401’로 나타납니다.
이 글에서는 kid 불일치가 왜 발생하는지, JWKS 캐시 버그의 전형적인 패턴, 그리고 실무에서 안전하게 고치는 방법(코드 예제 포함)을 정리합니다.
JWT의 kid와 JWKS의 관계(문제의 핵심)
JWT 헤더에는 대개 다음이 들어있습니다.
alg: 서명 알고리즘(예: RS256)kid: 어떤 공개키로 검증해야 하는지 식별자
검증 서버는 보통 다음 순서로 동작합니다.
- JWT 헤더에서
kid를 읽음 - IdP(예: Auth0, Cognito, Keycloak)의
jwks_uri에서 JWKS(JSON Web Key Set)를 가져옴 - JWKS 배열에서
kid가 동일한 JWK를 찾아 공개키로 서명 검증
즉, 토큰의 kid가 가리키는 키가 JWKS에 반드시 존재해야 합니다.
그런데 IdP는 키를 롤오버합니다.
- 새 키를 만들고(새
kid) - 일정 기간 동안 구키/신키를 함께 JWKS에 노출한 뒤
- 구키를 제거합니다.
이 과정에서 애플리케이션이 오래된 JWKS를 캐시하고 있거나, 캐시 갱신 로직이 레이스 컨디션을 일으키면 kid mismatch 형태의 401이 발생합니다.
증상 패턴: “어떤 요청은 되고 어떤 요청은 401”
kid 불일치 문제는 아래 패턴으로 나타나는 경우가 많습니다.
- 같은 사용자/같은 토큰인데 어떤 파드에서는 성공, 어떤 파드에서는 실패
- 배포 직후/스케일아웃 직후에만 401이 증가
- 401이 수 분~수십 분 간격으로 파도처럼 발생했다가 사라짐
이는 인스턴스별 JWKS 캐시 상태가 달라서 생기는 전형적인 증상입니다.
쿠버네티스에서 로그/지표로 이런 간헐 오류를 추적할 때는, 인증 실패율이 특정 파드에 치우치는지부터 확인하는 게 빠릅니다. (운영 점검 흐름은 EKS에서 fluent-bit 로그 누락·지연 원인 9가지 같은 로그 파이프라인 점검과 함께 보면 도움이 됩니다.)
원인 1) JWKS를 “너무 오래” 캐시함(TTL 과다)
가장 흔한 실수는 JWKS를 애플리케이션 시작 시 한 번만 가져오고, 프로세스가 살아있는 동안 갱신하지 않는 것입니다.
- IdP 키 롤오버 발생
- 새로 발급된 토큰은 새
kid - 서버는 구 JWKS만 가지고 있어
kid not found→ 401
체크리스트
- JWKS 캐시 TTL이 1시간 이상으로 과도하지 않은가?
- IdP가 제공하는
Cache-Control,max-age를 무시하고 있지 않은가? - 애플리케이션 재시작으로만 JWKS가 갱신되는 구조는 아닌가?
원인 2) “kid miss” 시에도 JWKS를 갱신하지 않음
정상적인 전략은 이렇습니다.
- 캐시에
kid가 없다면 - 즉시 JWKS를 한 번 강제 갱신하고
- 그래도 없으면 실패(401)
그런데 구현이 다음처럼 되어 있으면 문제가 됩니다.
- TTL이 남아있다는 이유로 갱신을 안 함
- 결과적으로 TTL 만료까지 계속 401
특히 트래픽이 많은 서비스에서는 kid miss가 발생한 순간부터 대량의 인증 실패가 이어집니다.
원인 3) JWKS 캐시 갱신의 동시성 버그(레이스 컨디션)
멀티 스레드/멀티 요청 환경에서 흔한 버그는 다음입니다.
- 여러 요청이 동시에
kid miss를 겪음 - 모두 JWKS 갱신을 시도
- 갱신 중간 상태의 캐시를 다른 스레드가 읽거나
- 실패한 갱신 결과(빈 키셋, 부분 파싱)를 캐시에 덮어씀
결과:
- 정상 JWKS가 있는데도 순간적으로 “키가 없다”가 발생
- 간헐적 401, 재현 어려움
특히 위험한 구현
- 갱신 중 캐시를
null/빈 배열로 먼저 초기화 - 네트워크 실패 시에도 캐시를 빈 값으로 덮어씀
- 락 없이 전역 변수를 교체
원인 4) 네트워크/프록시 캐시로 “옛 JWKS”를 받음
IdP의 JWKS 엔드포인트는 보통 CDN/캐시를 타며, 조직 내부 프록시가 끼어 있으면 더 복잡해집니다.
- 어떤 인스턴스는 최신 JWKS
- 어떤 인스턴스는 프록시에서 캐시된 구 JWKS
이 경우는 애플리케이션 캐시를 잘 짜도 해결이 안 됩니다.
진단 팁
- JWKS 응답 헤더의
Age,Cache-Control,ETag확인 - 인스턴스별로 동일 시점에 JWKS를 직접 curl해서
kid목록 비교
재현 시나리오: 키 롤오버 순간의 kid miss
개발/스테이징에서 재현하려면 아래처럼 “인위적 롤오버”가 필요합니다.
- IdP에서 새 키를 생성(또는 키 로테이션 트리거)
- 새 토큰을 발급해
kid=newKid확인 - 서비스는 의도적으로 구 JWKS를 캐시(또는 TTL 길게)
- 새 토큰으로 호출 → 401
이때 서버 로그에 다음을 남기면 추적이 빨라집니다.
- JWT 헤더의
kid - 캐시된 JWKS의
kid목록(해시/요약) - JWKS 마지막 갱신 시각
> 보안상 JWT 전체를 로깅하지 말고, 헤더/클레임 일부만 마스킹해 남기세요.
해결 전략: “kid miss 시 단발 갱신 + 안전한 캐시”
실무에서 가장 안정적인 정책은 아래 조합입니다.
- 정상 TTL 캐시: IdP가 준
Cache-Control: max-age를 존중(상한선/하한선 설정) - kid miss 시 강제 갱신: TTL이 남아있어도 1회 갱신 시도
- 싱글플라이트(single-flight): 갱신은 한 번만 수행, 나머지는 대기
- Stale-while-revalidate: 갱신 실패 시 기존 캐시를 유지(빈 값으로 덮지 않기)
- 타임아웃/재시도 제한: JWKS fetch는 짧은 타임아웃, 제한된 재시도
아래는 Node.js(Express)에서 많이 쓰는 jose 기반 예시입니다. 핵심은 kid가 없을 때 강제 re-fetch를 하되, 동시성 갱신을 한 번으로 묶는 것입니다.
코드 예제(Node.js): JWKS 캐시 + single-flight + kid miss refresh
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS_URL = new URL('https://idp.example.com/.well-known/jwks.json');
// jose의 RemoteJWKSet은 내부 캐시가 있지만,
// 운영 정책에 맞게 래핑해서 "kid miss 시 강제 갱신"을 구현할 수 있다.
let jwks = createRemoteJWKSet(JWKS_URL, {
timeoutDuration: 1500, // 네트워크 지연이 인증 전체를 망치지 않게 짧게
cooldownDuration: 30_000, // 과도한 재요청 방지
});
// single-flight용 Promise
let refreshInFlight = null;
async function refreshJwksOnce() {
if (refreshInFlight) return refreshInFlight;
refreshInFlight = (async () => {
// createRemoteJWKSet 자체를 새로 만들어 캐시를 강제로 갱신하는 방식
// (환경에 따라 더 나은 방식: ETag 기반 fetch + 캐시 교체)
jwks = createRemoteJWKSet(JWKS_URL, {
timeoutDuration: 1500,
cooldownDuration: 30_000,
});
})();
try {
await refreshInFlight;
} finally {
refreshInFlight = null;
}
}
export async function verifyAccessToken(token, options = {}) {
const { issuer, audience } = options;
try {
return await jwtVerify(token, jwks, { issuer, audience });
} catch (err) {
// kid miss 계열 에러일 때만 1회 강제 갱신 후 재시도
const msg = String(err?.message || '');
const isKidProblem =
msg.includes('no applicable key') ||
msg.includes('Unable to find a signing key') ||
msg.includes('JWK') ||
msg.includes('kid');
if (!isKidProblem) throw err;
await refreshJwksOnce();
// 재시도 1회
return await jwtVerify(token, jwks, { issuer, audience });
}
}
이 코드가 막는 버그
kid miss가 발생하면 TTL이 남아도 즉시 갱신- 동시에 100개 요청이 들어와도 갱신은 1회
- 갱신 실패 시에도 기존
jwks객체를 바로 날리지 않음(빈 캐시로 덮을 위험 감소)
> 언어/프레임워크가 다르더라도 원리는 동일합니다. “갱신은 원자적으로 교체하고, 실패 시 기존 캐시를 유지하며, kid miss에만 선별적으로 강제 갱신”이 포인트입니다.
운영 체크: 관측 가능한 지표/로그를 만들기
문제를 ‘감’으로 잡으면 재발합니다. 아래를 지표로 박아두면 키 롤오버 때도 덜 흔들립니다.
jwt_verify_fail_total{reason="kid_not_found"}jwks_refresh_total{result="success|fail"}jwks_last_refresh_timestampjwks_keys_count
쿠버네티스/EKS라면 파드 단위로 라벨링해 특정 파드에만 401이 몰리는지를 바로 볼 수 있어야 합니다. 리소스/지표 수집이 꼬여 원인 파악이 늦어지는 경우도 많으니, 인프라 지표가 이상할 때는 EKS에서 kubectl top이 0%일 때 Metrics API 점검 같은 기본 점검도 같이 해두면 좋습니다.
자주 놓치는 보안/설정 포인트
1) 알고리즘 혼동(alg none / HS256 vs RS256)
kid 문제처럼 보여도 실제로는 alg 설정이 잘못된 경우가 있습니다.
- 서버는 RS256만 허용해야 하는데, 라이브러리가 토큰 헤더를 그대로 신뢰
- 혹은 HS256/RS256 혼동 취약점 방어가 미흡
대응:
- 허용 알고리즘을 서버에서 고정
issuer,audience를 반드시 검증
2) 시간 동기화(NTP) 문제
키 문제는 아닌데, exp, nbf 검증에서 실패해 401이 날 수 있습니다. 특히 노드 시간 드리프트가 있으면 간헐적으로 보입니다.
3) 멀티 리전/멀티 IdP 엔드포인트
리전별로 JWKS가 다르거나(드물지만), 테넌트/유저풀을 잘못 바라보면 영구적으로 kid not found가 납니다.
결론: kid 불일치는 “토큰 문제”가 아니라 “캐시 설계 문제”인 경우가 많다
JWT kid 불일치로 401이 보이면, 토큰 자체를 의심하기 전에 아래부터 확인하는 게 가장 빠릅니다.
- 우리 서비스는 JWKS를 어떻게 캐시하는가(TTL, 갱신 조건)
kid miss시 강제 갱신을 하는가- 갱신 로직이 동시성 안전한가(single-flight, 원자적 교체)
- 네트워크/프록시가 옛 JWKS를 주고 있지 않은가
키 롤오버는 “언젠가 반드시” 일어납니다. 그 순간 401이 폭발하지 않게 하려면, JWKS 캐시는 단순한 메모이제이션이 아니라 **장애를 견디는 캐시(실패 내성 + 동시성 제어)**로 다뤄야 합니다.
추가로, CI/CD나 런타임에서 OIDC/JWT 기반 인증을 많이 쓰는 환경이라면, 인증/권한 오류가 401/403으로 섞여 보일 때가 많습니다. AWS 연동에서 403이 얽히는 케이스는 GitHub Actions OIDC로 AWS 배포 403 해결 가이드도 같이 참고해두면 진단 속도가 훨씬 빨라집니다.