Published on

JWT kid 악용과 JWK 캐시·키로테이션 보안

Authors

서버가 JWT를 검증할 때 가장 먼저 보는 값 중 하나가 헤더의 kid입니다. kidkey id로, 여러 공개키 중 어떤 키로 서명을 검증해야 하는지 선택하는 힌트입니다. 문제는 이 값이 공격자가 마음대로 조작 가능한 입력이라는 점입니다.

kid 자체가 취약한 것은 아니지만, 구현이 조금만 삐끗하면 다음과 같은 사고로 이어집니다.

  • 잘못된 키 선택으로 인한 키 혼동(key confusion)
  • JWK를 가져오는 과정에서의 SSRF 또는 내부망 스캔
  • 캐시 미스 폭증 및 원격 JWK 재조회로 인한 DoS
  • 키로테이션 중 캐시 불일치로 인한 인증 장애

이 글에서는 kid 악용 시나리오를 현실적으로 정리하고, JWK 캐시와 키로테이션을 안전하게 설계하는 방법을 코드와 함께 설명합니다. 운영 관점에서의 장애 방지 포인트도 포함합니다.

kid는 “선택자”이지 “신뢰할 값”이 아니다

JWT는 보통 header.payload.signature 구조이고, 헤더에는 alg, kid, typ 등이 들어갑니다. 예시는 다음과 같습니다.

{
  "alg": "RS256",
  "kid": "2026-01-15-key-01",
  "typ": "JWT"
}

여기서 중요한 원칙은 하나입니다.

  • kid키를 찾기 위한 인덱스일 뿐, 신뢰 경계 밖(untrusted input)이다.

따라서 서버는 kid를 그대로 파일 경로, URL, DB 쿼리, 캐시 키로 사용하기 전에 형식 제한·화이트리스트·길이 제한을 반드시 적용해야 합니다.

kid 악용이 실제로 터지는 지점 4가지

1) kid를 URL로 취급해 JWK를 가져오는 구현

가장 위험한 패턴은 “JWT 헤더의 kid가 가리키는 곳에서 키를 가져오자” 같은 설계입니다.

  • kidhttps://attacker.example/jwks.json 같은 값으로 들어오면 서버가 원격 호출
  • 더 나쁘면 http://169.254.169.254/ 같은 메타데이터 주소로 SSRF

안전한 설계에서는 JWK Set URL(jwks_uri)은 고정이며, kid는 그 JWK Set 안에서만 매칭합니다.

2) 캐시 키 폭발로 인한 DoS

JWK를 kid 단위로 캐시하고, kid가 무제한으로 들어오면 캐시가 다음을 유발합니다.

  • 메모리 캐시가 kid 랜덤값으로 오염
  • 매번 캐시 미스가 발생해 원격 jwks_uri 재조회
  • 인증 요청이 곧 외부 호출이 되어 레이트리밋/지연/장애

이 문제는 특히 트래픽이 큰 API 게이트웨이에서 치명적입니다.

3) 알고리즘 혼동과 결합되는 키 선택 취약점

alg를 토큰 헤더에서 그대로 신뢰하거나, RS256HS256을 혼용 지원하면서 검증 키를 잘못 선택하면 유명한 alg 혼동류 취약점으로 이어집니다.

핵심은 다음 두 가지를 강제하는 것입니다.

  • 서버 설정에서 허용 알고리즘을 고정하고, 토큰 헤더의 alg와 일치 여부를 검사
  • 공개키 검증(RS256/ES256)과 대칭키 검증(HS256)을 같은 경로로 합치지 않기

4) 키로테이션 중 캐시 불일치로 인한 대규모 인증 실패

운영에서 더 자주 터지는 건 공격보다 키로테이션 장애입니다.

  • IdP가 새 키를 발급하고 kid가 바뀜
  • 서비스는 이전 JWK를 캐시 중
  • 캐시 TTL 동안 새 토큰이 전부 401

즉, 보안만이 아니라 가용성 설계가 필요합니다.

안전한 JWK 조회의 기본: jwks_uri는 고정, kid는 로컬 매칭

정석 구조는 다음과 같습니다.

  1. 테넌트 또는 발급자(iss)를 기준으로 **고정된 jwks_uri**를 선택
  2. jwks_uri에서 받아온 JWK Set을 캐시
  3. 토큰의 kid캐시된 JWK Set 내부에서만 키를 선택

다음은 Node.js에서 jose 라이브러리로 구현하는 예시입니다.

import { jwtVerify, createRemoteJWKSet } from 'jose'

const ISSUER = 'https://idp.example.com'
const AUDIENCE = 'my-api'

// jwks_uri는 고정된 신뢰 설정(환경변수/설정파일)에서만 가져온다.
const JWKS = createRemoteJWKSet(new URL('https://idp.example.com/.well-known/jwks.json'))

export async function verify(token) {
  const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
    issuer: ISSUER,
    audience: AUDIENCE,
    algorithms: ['RS256'],
  })

  // kid는 로깅/메트릭용으로만 취급하고, 신뢰 판단에 직접 사용하지 않는다.
  return { payload, kid: protectedHeader.kid }
}

위 방식의 장점은 kid가 어떤 값이든 원격 호출 경로를 바꾸지 못한다는 점입니다.

kid 입력 검증: 허용 포맷, 길이, 문자셋을 제한하라

kid를 직접 URL로 쓰지 않더라도, 캐시 키나 로그에 들어가면 문제가 됩니다.

  • 너무 긴 kid로 로그/메모리/헤더 처리 비용 증가
  • 제어문자 포함으로 로그 인젝션
  • 경로 조작 문자열이 다른 레이어에서 오동작

권장 정책 예시는 다음과 같습니다.

  • 길이: 1..64 또는 1..128
  • 문자셋: A-Z a-z 0-9 _ - . 정도로 제한
  • 그 외는 즉시 거부(401) 또는 kid 무시 후 전체 키 탐색(비권장)
export function validateKid(kid: unknown): string | null {
  if (typeof kid !== 'string') return null
  if (kid.length < 1 || kid.length > 64) return null
  if (!/^[A-Za-z0-9_.-]+$/.test(kid)) return null
  return kid
}

주의할 점은 “유효하지 않은 kid면 전체 키를 돌며 검증” 같은 관대한 동작이 오히려 DoS를 부를 수 있다는 것입니다. 보통은 명확히 거부하는 편이 안전합니다.

JWK 캐시 설계: “키 단위”보다 “세트 단위”가 안전하다

실무에서는 kid별로 키를 캐시하기보다, JWK Set 전체를 issuer 단위로 캐시하는 것이 운영과 보안에 유리합니다.

  • 캐시 키: issuer 또는 tenantId
  • 캐시 값: JWK Set(JSON)과 파싱된 키 객체들
  • 선택은 캐시 내부에서 kid로 매칭

이렇게 하면 kid 랜덤값으로 캐시가 오염되는 위험이 크게 줄어듭니다.

캐시 TTL과 재검증 전략

권장 접근은 다음 조합입니다.

  • 짧은 TTL(예: 5~15분) + 백그라운드 리프레시
  • 또는 HTTP 캐시 헤더(Cache-Control, ETag) 존중

jwks.json은 대개 CDN 앞에 있고, ETag를 제공하는 경우가 많습니다. 가능하면 조건부 요청으로 트래픽을 줄이세요.

실패 시 동작: “캐시된 마지막 정상값”을 유지

운영에서 중요한 포인트는 원격 JWK 조회 실패 시 정책입니다.

  • IdP 장애, 네트워크 오류가 곧바로 전체 인증 장애로 번지지 않게
  • 마지막으로 성공한 JWK Set을 일정 기간 더 사용

단, 너무 오래 유지하면 폐기된 키를 계속 신뢰하는 문제가 생기므로, 별도의 stale-while-revalidate 같은 제한을 둡니다.

키로테이션: 새 키 추가 후, 구 키 제거는 늦게

키로테이션을 안전하게 하려면 “추가”와 “제거”를 한 번에 하지 말아야 합니다.

권장 순서

  1. IdP에 새 키를 생성하고 JWK Set에 추가
  2. kid로 서명된 토큰 발급을 시작(점진적)
  3. 서비스는 캐시 갱신을 통해 새 키를 인지
  4. 충분한 유예 기간(최대 토큰 수명 + 여유) 후 구 키를 JWK Set에서 제거

여기서 유예 기간은 대개 다음과 같이 산정합니다.

  • max(access token TTL) + clock skew + 캐시 TTL + 운영 여유

예를 들어 액세스 토큰이 1시간이고 캐시 TTL이 10분이면, 최소 1시간 10분 이상은 구 키를 유지하는 식입니다.

토큰 수명과 캐시 TTL의 관계

  • 토큰 TTL이 길수록 구 키 제거가 늦어져야 합니다.
  • 캐시 TTL이 길수록 새 키 반영이 느려져 장애 가능성이 커집니다.

즉 “토큰 TTL을 길게” 가져가면서 “JWK 캐시 TTL도 길게” 가져가면 키로테이션은 거의 항상 사고가 납니다.

멀티 테넌트/멀티 issuer에서의 추가 방어

여러 iss를 지원하는 시스템에서 흔한 실수는 iss를 토큰에서 읽고 그 값을 기반으로 동적으로 jwks_uri를 구성하는 것입니다. 이 경우 iss 자체가 공격자 입력이므로, 사실상 SSRF 표면이 됩니다.

권장 패턴은 다음 중 하나입니다.

  • 허용 issuer 목록을 설정으로 고정하고, 매칭되는 issuer만 허용
  • iss -> jwks_uri 매핑 테이블을 사전에 구성(코드/설정/DB)하고, 임의 URL 조합 금지

-> 표기는 MDX에서 오해될 수 있으니 본문에서는 -로 표현하거나 인라인 코드로 처리하는 것을 습관화하세요. 예: iss -> jwks_uri.

관측 가능성: kid 기반 메트릭으로 “로테이션 장애”를 빨리 잡기

키로테이션 이슈는 보통 배포가 아니라 인증 계층에서 터지기 때문에, 애플리케이션 로그만 보고는 늦습니다.

추천 메트릭:

  • jwt_verify_success_total{iss,kid}
  • jwt_verify_fail_total{reason,iss,kid}
  • jwks_fetch_total{iss,status}
  • jwks_cache_hit_ratio{iss}

특히 reason을 세분화하세요.

  • kid_not_found
  • invalid_signature
  • invalid_issuer
  • aud_mismatch

이런 메트릭을 OpenTelemetry로 내보내면 원인 파악이 빨라집니다. 분산 추적을 통한 장애 추적 관점은 AutoGPT 툴콜 무한루프, OpenTelemetry로 추적하기에서도 접근 방식이 유사합니다.

실전 체크리스트

아래 항목을 만족하면 kid 악용과 키로테이션 장애 대부분을 예방할 수 있습니다.

검증 로직

  • 허용 algorithms를 서버 설정으로 고정하고, 토큰 헤더의 alg를 그대로 신뢰하지 않기
  • iss, aud, exp, nbf를 반드시 검증
  • kid는 형식 검증(길이/문자셋) 후 사용

JWK 조회

  • jwks_uri는 토큰에서 유도하지 말고 신뢰 설정에서만 가져오기
  • JWK Set 단위로 캐시하고, 캐시 키는 iss 또는 테넌트로 제한
  • 원격 조회 실패 시 마지막 정상 캐시를 제한적으로 사용

키로테이션 운영

  • 새 키는 먼저 추가, 구 키 제거는 토큰 TTL 이후로 지연
  • 로테이션 직후 kid_not_found 급증 알람 설정
  • 캐시 TTL을 너무 길게 잡지 않기

네트워크 계층의 일시 장애가 인증 장애로 번지는 패턴은 외부 API 호출에서도 흔합니다. 재시도/타임아웃/서킷브레이커 같은 운영 설계는 Node.js fetch ECONNRESET·ETIMEDOUT 해결법에서 다룬 접근을 참고해도 좋습니다.

결론: kid는 편의 기능, 보안은 “경계”에서 결정된다

kid는 다중 키 환경에서 성능과 편의성을 주지만, 그 자체로 신뢰할 수 있는 값은 아닙니다. 안전한 설계의 핵심은 단순합니다.

  • 원격 키 소스(jwks_uri)는 고정하고
  • kid는 로컬에서만 매칭하며
  • 캐시는 세트 단위로 통제하고
  • 키로테이션은 “추가 후 제거” 원칙으로 운영한다

이 네 가지를 지키면 kid 악용 가능성을 크게 줄이고, 키로테이션 때도 무중단에 가깝게 운영할 수 있습니다.