Published on

JWT kid 불일치로 401? JWKS 캐시 버그 추적

Authors

서버에서 JWT 검증을 붙여두면, 평소엔 조용하다가 갑자기 일부 요청만 401 Unauthorized가 터지는 날이 있습니다. 로그를 보면 대개 이런 메시지가 같이 따라옵니다.

  • kid not found
  • Unable 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: 어떤 공개키로 검증해야 하는지 식별자

검증 서버는 보통 다음 순서로 동작합니다.

  1. JWT 헤더에서 kid를 읽음
  2. IdP(예: Auth0, Cognito, Keycloak)의 jwks_uri에서 JWKS(JSON Web Key Set)를 가져옴
  3. 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

개발/스테이징에서 재현하려면 아래처럼 “인위적 롤오버”가 필요합니다.

  1. IdP에서 새 키를 생성(또는 키 로테이션 트리거)
  2. 새 토큰을 발급해 kid=newKid 확인
  3. 서비스는 의도적으로 구 JWKS를 캐시(또는 TTL 길게)
  4. 새 토큰으로 호출 → 401

이때 서버 로그에 다음을 남기면 추적이 빨라집니다.

  • JWT 헤더의 kid
  • 캐시된 JWKS의 kid 목록(해시/요약)
  • JWKS 마지막 갱신 시각

> 보안상 JWT 전체를 로깅하지 말고, 헤더/클레임 일부만 마스킹해 남기세요.

해결 전략: “kid miss 시 단발 갱신 + 안전한 캐시”

실무에서 가장 안정적인 정책은 아래 조합입니다.

  1. 정상 TTL 캐시: IdP가 준 Cache-Control: max-age를 존중(상한선/하한선 설정)
  2. kid miss 시 강제 갱신: TTL이 남아있어도 1회 갱신 시도
  3. 싱글플라이트(single-flight): 갱신은 한 번만 수행, 나머지는 대기
  4. Stale-while-revalidate: 갱신 실패 시 기존 캐시를 유지(빈 값으로 덮지 않기)
  5. 타임아웃/재시도 제한: 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_timestamp
  • jwks_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 해결 가이드도 같이 참고해두면 진단 속도가 훨씬 빨라집니다.