Published on

JWT kid 헤더 악용 방지 - JWK 키회전 검증 7단계

Authors

서버가 JWT를 검증할 때 kid 값으로 JWK를 선택하는 패턴은 매우 흔합니다. 문제는 이 kid공격자가 마음대로 바꿀 수 있는 입력값이라는 점입니다. 키회전(key rotation)을 운영하면서 JWK Set을 원격에서 받아오면, kid 기반 키 선택 로직이 취약해지기 쉬운 지점들이 생깁니다.

이 글은 kid 헤더 악용 시나리오를 짚고, JWK 키회전 환경에서 안전하게 검증하는 7단계를 실무 관점에서 정리합니다. 목표는 단순히 “서명 검증 성공”이 아니라, 키 선택 과정 자체를 공격 표면으로 만들지 않는 것입니다.

관련해서 “재시도/백오프”나 “7단계 진단 체크리스트” 같은 운영 패턴이 도움이 될 때가 많습니다. 필요하면 다음 글도 함께 참고하세요.

kid 헤더는 왜 위험해지나

JWT는 대략 header.payload.signature 형태이고, 헤더에는 보통 alg, typ, kid가 들어갑니다. 여기서 kid는 “이 토큰은 어떤 키로 서명했는지”를 식별하는 힌트일 뿐, 신뢰할 수 있는 메타데이터가 아닙니다.

kid 악용이 문제 되는 대표 상황은 다음과 같습니다.

  • 키 선택 우회: 서버가 kid를 그대로 신뢰해 특정 키를 고르게 만들고, 그 키가 검증 정책에 맞지 않거나(예: 폐기된 키, 잘못된 용도의 키) 다른 테넌트의 키인 경우
  • 알고리즘 혼동(alg confusion): alg를 공격자가 바꿔도 서버가 이를 허용하거나, none 또는 대칭키/비대칭키 혼동을 일으키는 경우
  • 원격 JWK 조회를 이용한 DoS: 존재하지 않는 kid를 계속 던져서 매 요청마다 JWK를 재조회하게 만들기
  • 키 회전 타이밍 경합: 새 키/옛 키가 공존하는 window에서 캐시 전략이 부실하면 검증 실패 폭증 또는 불필요한 원격 호출이 발생

핵심은 kid를 “DB의 primary key”처럼 쓰는 순간 위험해진다는 점입니다. kid는 어디까지나 검색 키로만 쓰고, 검증은 별도의 정책으로 “이 키를 써도 되는가”를 다시 확인해야 합니다.

JWK 키회전 검증 7단계 체크리스트

1단계: 토큰 파싱은 하되, 헤더를 신뢰하지 말기

첫 단계는 단순합니다. 헤더를 읽어 kid를 얻되, 아래를 반드시 지킵니다.

  • 헤더의 alg정책적으로 고정하고, 토큰이 주장하는 alg는 참고만 하거나 아예 무시
  • typ 같은 필드는 보안 판단 기준으로 쓰지 않기
  • kid는 길이/문자셋을 제한하고 로깅 시에도 원문을 그대로 남기지 않기(로그 인젝션 방지)

예: kid를 허용할 문자셋과 길이로 제한합니다.

// TypeScript 예시
function validateKidFormat(kid: string) {
  // 예: base64url 또는 단순 식별자만 허용
  if (kid.length < 8 || kid.length > 128) throw new Error("invalid kid length");
  if (!/^[a-zA-Z0-9_-]+$/.test(kid)) throw new Error("invalid kid charset");
}

2단계: 허용 알고리즘 화이트리스트 + “키 타입” 일치 강제

가장 흔한 실수는 “토큰 헤더의 alg를 그대로 사용”하는 것입니다. 서버는 다음을 강제해야 합니다.

  • 허용 alg는 서비스 설정으로 고정(예: RS256만)
  • JWK의 ktyalg의 조합이 맞는지 확인(예: RS256이면 ktyRSA여야 함)
type AllowedAlg = "RS256";
const ALLOWED_ALG: AllowedAlg = "RS256";

function assertAlg(headerAlg: string | undefined) {
  if (headerAlg !== ALLOWED_ALG) throw new Error("disallowed alg");
}

function assertKeyTypeMatchesAlg(jwk: any) {
  // RS256이라면 RSA 키만 허용
  if (jwk.kty !== "RSA") throw new Error("kty mismatch");
}

여기서 중요한 포인트는 “서명 검증 라이브러리 호출 전에” 정책을 확정하는 것입니다.

3단계: kid로 찾은 키가 ‘정말 그 용도’인지 검증

JWK에는 use(예: sig) 또는 key_ops(예: verify) 같은 힌트가 들어갈 수 있습니다. 이 값은 표준상 필수는 아니지만, 제공된다면 반드시 활용하세요.

  • use가 있다면 sig만 허용
  • key_ops가 있다면 verify 포함 여부 확인
  • x5tx5c를 쓴다면 체인 검증 정책을 별도로 명시
function assertKeyUsage(jwk: any) {
  if (jwk.use && jwk.use !== "sig") throw new Error("invalid jwk use");
  if (Array.isArray(jwk.key_ops) && !jwk.key_ops.includes("verify")) {
    throw new Error("invalid key_ops");
  }
}

이 단계는 “같은 kid를 가진 다른 목적의 키” 같은 구성 실수나, 테넌트/환경 분리 실패를 조기에 잡아줍니다.

4단계: JWK Set은 ‘테넌트/Issuer 단위’로 분리하고, iss로 라우팅

멀티 테넌트 또는 여러 IdP를 붙이는 환경에서 가장 위험한 패턴은 “모든 issuer의 JWK를 한 캐시에 섞어두고 kid만으로 선택”하는 것입니다.

권장 구조:

  • 먼저 payload의 iss를 읽고(서명 검증 전이라도 라우팅 용도로만 사용), 허용 issuer 목록에 있는지 확인
  • issuer 별로 jwks_uri를 고정
  • 캐시도 issuer 별로 분리

주의: 서명 검증 전의 iss는 신뢰할 수 없으므로, “허용 목록에 있는지”만 확인하고 그 외 동작(동적 등록 등)은 금지하는 편이 안전합니다.

const ISSUER_TO_JWKS_URI: Record<string, string> = {
  "https://idp.example.com": "https://idp.example.com/.well-known/jwks.json",
};

function resolveJwksUri(unverifiedIss: string) {
  const uri = ISSUER_TO_JWKS_URI[unverifiedIss];
  if (!uri) throw new Error("unknown issuer");
  return uri;
}

5단계: JWK 조회 캐시 + 음수 캐시(negative cache)로 kid-DoS 차단

공격자는 존재하지 않는 kid를 계속 보내 서버가 매번 원격 jwks_uri를 호출하게 만들 수 있습니다. 이를 막으려면:

  • JWK Set을 캐시(예: 5분)
  • kid 미존재 결과도 짧게 캐시(예: 30초)
  • 캐시 만료 전에는 원격 호출을 하지 않기
// 매우 단순화한 메모리 캐시 예시
const jwksCache = new Map<string, { expiresAt: number; jwks: any }>();
const negativeKidCache = new Map<string, number>();

function cacheKey(issuer: string) {
  return `jwks:${issuer}`;
}

function negativeKey(issuer: string, kid: string) {
  return `neg:${issuer}:${kid}`;
}

function setNegative(issuer: string, kid: string, ttlMs: number) {
  negativeKidCache.set(negativeKey(issuer, kid), Date.now() + ttlMs);
}

function hasNegative(issuer: string, kid: string) {
  const exp = negativeKidCache.get(negativeKey(issuer, kid));
  return typeof exp === "number" && exp > Date.now();
}

운영 환경에서는 Redis 같은 외부 캐시를 쓰는 경우가 많고, 이때도 “issuer 단위 분리”는 그대로 적용됩니다.

6단계: 키회전 경합 처리: ‘한 번만’ 강제 리프레시하고 실패를 확정

키회전 직후에는 다음 상황이 자주 발생합니다.

  • IdP는 새 키로 서명했는데, API 서버 캐시에는 아직 옛 JWK Set만 있음
  • 이때 서버가 매 요청마다 무한 리프레시를 시도하면 장애로 번짐

권장 패턴:

  1. 캐시에서 kid를 찾는다
  2. 없으면 딱 한 번 JWK Set을 강제 리프레시한다
  3. 그래도 없으면 실패를 확정하고, 음수 캐시에 넣는다
async function findJwkWithSingleRefresh(issuer: string, kid: string, fetchJwks: () => Promise<any>) {
  if (hasNegative(issuer, kid)) throw new Error("kid not found (cached)");

  const cached = jwksCache.get(cacheKey(issuer));
  const now = Date.now();

  const lookup = (jwks: any) => (jwks.keys || []).find((k: any) => k.kid === kid);

  if (cached && cached.expiresAt > now) {
    const hit = lookup(cached.jwks);
    if (hit) return hit;
  }

  // single refresh
  const fresh = await fetchJwks();
  jwksCache.set(cacheKey(issuer), { jwks: fresh, expiresAt: now + 5 * 60 * 1000 });

  const hit2 = lookup(fresh);
  if (hit2) return hit2;

  setNegative(issuer, kid, 30 * 1000);
  throw new Error("kid not found");
}

여기서 fetchJwks는 네트워크 실패/레이트리밋을 고려해 재시도·백오프를 넣는 것이 일반적입니다. 원격 의존성이 있는 컴포넌트는 장애 전파를 막기 위해 재시도 정책이 중요합니다.

7단계: 서명 검증 후 클레임 검증(시간/대상/재사용 방지)까지 마무리

키를 골라 서명 검증에 성공해도 끝이 아닙니다. 최소한 아래를 검증해야 합니다.

  • iss가 허용 issuer와 일치
  • aud가 내 서비스 식별자와 일치
  • exp, nbf, iat 검증 및 clock skew 허용폭(예: 60초)
  • 필요 시 jti 기반 재사용 방지(특히 단발성 토큰)
  • 스코프/권한 클레임 검증
type Claims = { iss: string; aud: string | string[]; exp: number; nbf?: number; iat?: number };

function assertClaims(claims: Claims, expectedIss: string, expectedAud: string) {
  if (claims.iss !== expectedIss) throw new Error("iss mismatch");

  const audOk = Array.isArray(claims.aud)
    ? claims.aud.includes(expectedAud)
    : claims.aud === expectedAud;
  if (!audOk) throw new Error("aud mismatch");

  const now = Math.floor(Date.now() / 1000);
  const skew = 60;
  if (claims.exp < now - skew) throw new Error("token expired");
  if (claims.nbf && claims.nbf > now + skew) throw new Error("token not active");
}

서명 검증은 “위조 방지”이고, 클레임 검증은 “오용 방지”에 가깝습니다. 둘 중 하나라도 빠지면 사고가 납니다.

실무에서 자주 터지는 함정 5가지

  1. kid 미스 시 즉시 원격 JWK 조회: 캐시/음수 캐시가 없으면 손쉽게 DoS가 됩니다.
  2. issuer 혼합 캐시: kid는 전역 유일이 아닐 수 있습니다. issuer 단위로 분리하지 않으면 다른 issuer의 키로 검증되는 최악의 상황이 가능합니다.
  3. 알고리즘 가변 허용: alg는 서버 정책으로 고정해야 합니다.
  4. 폐기 키 처리 부재: JWK Set에서 키가 제거되었는데도 서버가 장시간 캐시하면, “폐기된 키로 서명된 토큰”을 더 오래 받아줄 수 있습니다.
  5. 관측성 부족: kid not found, jwks fetch failed, signature invalid를 한 카테고리로 묶으면 원인 파악이 매우 느려집니다.

운영 체크: 로깅/메트릭은 이렇게 잡자

보안과 운영을 동시에 만족하려면, 이벤트를 세분화해 관측해야 합니다.

  • 카운터
    • jwt_verify_success_total
    • jwt_verify_fail_signature_total
    • jwt_verify_fail_claims_total
    • jwks_fetch_total, jwks_fetch_fail_total
    • kid_not_found_total
  • 히스토그램
    • jwks_fetch_latency_ms
    • jwt_verify_latency_ms
  • 로그(민감정보 최소화)
    • issuer, kid는 길이 제한 후 기록
    • 토큰 원문/서명 전체는 기록하지 않기

마무리: “키 선택”이 보안의 절반이다

JWT 검증에서 많은 팀이 “서명만 맞으면 된다”에 집중하지만, 실제 사고는 서명 검증 이전의 키 선택 과정에서 터지는 경우가 많습니다. 특히 키회전이 들어가면 원격 JWK 조회, 캐시, 경합 처리까지 합쳐져 공격 표면이 넓어집니다.

정리하면, 다음 7단계를 습관처럼 적용하세요.

  1. 헤더 파싱은 하되 신뢰하지 않기
  2. 허용 alg 고정 + 키 타입 일치 강제
  3. JWK 용도(use/key_ops) 검증
  4. issuer 단위 라우팅과 캐시 분리
  5. JWK 캐시 + 음수 캐시로 kid-DoS 차단
  6. 키회전 경합 시 단 1회 리프레시 후 실패 확정
  7. 서명 후 클레임 검증으로 오용 방지

이 7단계를 기준으로 현재 구현을 점검하면, kid 악용 리스크를 크게 줄이면서도 키회전 운영 안정성까지 함께 끌어올릴 수 있습니다.