- Published on
JWT kid 조작 취약점과 JWKS 캐시·검증 실무
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 JWT를 검증할 때 흔히 kid(Key ID)를 보고 JWKS에서 공개키를 찾아 서명을 검증합니다. 문제는 kid가 서명 대상인 헤더에 포함되긴 하지만, 구현 실수나 캐시/로테이션 설계가 허술하면 공격자가 kid를 조작해 잘못된 키를 선택하게 만들거나, 지속적인 키 조회로 서비스를 불안정하게 만들 수 있다는 점입니다.
이 글은 “kid 조작”이 실제로 어떤 형태로 나타나는지, 그리고 JWKS 캐시와 키 로테이션이 얽힌 환경에서 어떻게 안전하고 운영 친화적으로 검증을 구성할지 다룹니다.
관련해서 Spring 기반에서 kid 불일치와 캐시로 401이 나는 전형적인 케이스는 아래 글도 함께 참고하면 좋습니다.
kid 조작 취약점이 생기는 전형적인 조건
1) kid를 “신뢰 가능한 라우팅 키”처럼 쓰는 경우
정상 흐름은 다음과 같습니다.
- JWT 헤더의
kid를 읽는다. - JWKS에서
kid가 일치하는 JWK를 찾는다. - 해당 공개키로 서명을 검증한다.
여기서 핵심은 kid는 힌트일 뿐이라는 점입니다. kid는 “이 토큰은 이 키로 서명했으니 그 키를 찾아봐”라는 안내자 역할이지, kid 자체가 신뢰의 근거가 아닙니다. 신뢰의 근거는 오직 서명 검증 성공 여부입니다.
하지만 구현이 잘못되면 아래 같은 문제가 생깁니다.
kid가 특정 테넌트/Issuer로 매핑되도록 설계되어 있고, 그 매핑이 취약함kid값으로 URL을 조합해 JWKS 엔드포인트를 동적으로 호출함(SSRF 위험)kid가 없거나 이상할 때 “검증을 느슨하게” 처리함(예: 기본 키로 검증 시도 후 예외 무시)
2) 캐시/로테이션 환경에서 “키 선택 실패”가 곧 장애가 되는 경우
운영 환경에서는 JWKS를 매 요청마다 가져오지 않고 캐시합니다.
- IdP가 키를 로테이션함
- 리소스 서버의 JWKS 캐시가 아직 갱신되지 않음
- 토큰은 새
kid로 발급됨 - 서버는
kid를 못 찾아 401을 반환
이 자체는 “취약점”이라기보다 “운영 이슈”지만, 공격자가 kid를 무작위로 바꾼 토큰을 대량으로 던지면 다음 현상이 발생할 수 있습니다.
- 캐시 미스 유발로 JWKS 재조회 폭증
- IdP 또는 리소스 서버의 네트워크/CPU 사용량 증가
- 결과적으로 인증 계층이 병목이 되어 서비스 품질 저하
즉, kid 조작은 검증 우회뿐 아니라 가용성 공격(DoS) 촉매로도 이어질 수 있습니다.
공격/장애 시나리오로 보는 위험 포인트
시나리오 A: kid로 JWKS URL을 조립하는 실수
나쁜 예는 다음과 같습니다.
kid가tenantA면https://idp.example.com/tenantA/.well-known/jwks.jsonkid가tenantB면https://idp.example.com/tenantB/.well-known/jwks.json
이런 식으로 kid를 테넌트 식별자로 쓰고, 그 값을 URL 경로/호스트로 조립하면 kid가 곧 입력값이 되어 SSRF나 내부망 스캔으로 이어질 수 있습니다.
대응 원칙
- JWKS URI는
issuer별로 서버 설정에 고정(allowlist) kid는 “선택된 JWKS 집합 내부에서 키를 고르는 용도”로만 사용
시나리오 B: kid 미스가 곧바로 JWKS 재조회 트리거
많은 구현이 다음 로직을 탑니다.
- 캐시에서
kid를 못 찾음 - JWKS를 새로 가져옴
- 그래도 없으면 실패
공격자가 kid를 랜덤으로 넣은 토큰을 대량 전송하면, 서버는 매번 JWKS를 재조회하려고 하면서 외부 호출이 폭증할 수 있습니다.
대응 원칙
- “
kid미스”를 이유로 무조건 JWKS를 갱신하지 말고, 쿨다운과 **음수 캐싱(negative caching)**을 둠 - 동일한
issuer에 대해 JWKS 갱신은 동시성 제어(싱글플라이트)
시나리오 C: 알고리즘 혼선(alg)과 결합
kid 조작과 함께 자주 언급되는 것이 alg 혼선입니다.
- 토큰 헤더의
alg를none으로 바꿔 검증을 우회 - RS256을 기대하는데 HS256으로 바꿔 공개키를 HMAC 시크릿처럼 쓰게 유도
요즘 라이브러리들은 대부분 방어하지만, 구성 실수(알고리즘 허용 범위를 넓게 열어둠)로 여전히 사고가 납니다.
대응 원칙
- 허용 알고리즘을 정확히 고정(예: RS256만)
issuer,audience검증은 필수
실무 검증 체크리스트: kid를 어떻게 다뤄야 하나
1) issuer 기준으로 JWKS를 고정하고 allowlist로 제한
멀티 Issuer를 지원해야 한다면, 다음처럼 “설정 기반 맵”을 둡니다.
issuerhttps://idpA.example.com->JWKS URI Aissuerhttps://idpB.example.com->JWKS URI B
중요한 점은 토큰에 들어있는 iss를 곧바로 신뢰해서 JWKS URI를 찾지 말고, 애플리케이션이 허용하는 iss 목록(allowlist)에 포함되는지 먼저 확인하는 것입니다.
2) kid는 길이/문자셋 제한을 두고 로깅 시 이스케이프
kid는 헤더 값이므로 공격자가 임의 문자열을 넣을 수 있습니다.
- 길이 제한(예: 1~128)
- 허용 문자 제한(예:
A-Za-z0-9._-) - 로깅 시 줄바꿈/제어문자 제거(로그 인젝션 방지)
kid가 제한을 벗어나면 바로 401로 종료하고, JWKS 재조회 같은 비용 큰 작업을 하지 않는 게 좋습니다.
3) kid 미스 시 JWKS 갱신은 “조건부”로
추천 전략은 아래 조합입니다.
- JWKS는 TTL 기반 캐시(예: 10분)
kid미스가 발생해도 TTL이 남아있다면 즉시 갱신하지 않음- 단, “최근에 갱신한 적이 없다” 같은 조건을 만족하면 1회 갱신
- 갱신 중에는 동일 issuer에 대해 다른 스레드는 대기(싱글플라이트)
kid미스에 대해 짧은 음수 캐시(예: 30초)로 재시도 폭주 방지
이렇게 하면 로테이션 직후의 401을 줄이면서도, 공격자가 kid 랜덤화를 하더라도 외부 호출 폭증을 완화할 수 있습니다.
4) aud, iss, exp, nbf, typ를 “항상” 검증
서명만 맞으면 끝이라고 생각하면 위험합니다.
iss: 허용된 발급자만aud: 이 API가 대상인지exp,nbf: 시간 기반 유효성typ:JWT또는at+jwt등 기대 타입(운영 정책에 따라)
코드 예제 1: Node.js에서 kid 미스 폭주 방지(개념 구현)
아래는 jose 기반으로 “issuer 고정 + JWKS 캐시 + kid 미스 쿨다운”을 단순화해 표현한 예시입니다. (프로덕션에서는 관측/동시성 제어를 더 탄탄히 넣어야 합니다.)
import { jwtVerify, createRemoteJWKSet } from 'jose'
const ISSUER = 'https://idp.example.com'
const AUDIENCE = 'api://orders'
const JWKS_URL = new URL('https://idp.example.com/.well-known/jwks.json')
// jose의 RemoteJWKSet은 내부 캐시를 가짐. 여기서는 추가로 kid-miss 쿨다운을 얹는다.
const JWKS = createRemoteJWKSet(JWKS_URL)
const negativeKidCache = new Map() // kid -> expireAt
function isKidSane(kid) {
if (typeof kid !== 'string') return false
if (kid.length < 1 || kid.length > 128) return false
return /^[A-Za-z0-9._-]+$/.test(kid)
}
export async function verifyAccessToken(raw) {
// 헤더를 먼저 파싱해 kid sanity check (서명 검증 전 가드)
const [h] = raw.split('.')
const header = JSON.parse(Buffer.from(h, 'base64url').toString('utf8'))
const kid = header.kid
if (!isKidSane(kid)) {
throw new Error('invalid kid')
}
const now = Date.now()
const neg = negativeKidCache.get(kid)
if (neg && neg > now) {
// 최근에 kid 미스로 실패했던 값이면, 비용 큰 검증 경로를 반복하지 않음
throw new Error('kid not found (cached)')
}
try {
const { payload } = await jwtVerify(raw, JWKS, {
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ['RS256'],
})
return payload
} catch (e) {
// 라이브러리 내부에서 kid 미스/키 선택 실패가 났을 때만 짧게 음수 캐싱
// 에러 문자열 매칭은 취약하니, 실제로는 에러 타입/코드 기반 분기 권장
negativeKidCache.set(kid, now + 30_000)
throw e
}
}
포인트는 다음입니다.
kid가 비정상일 때 즉시 컷algorithms를RS256으로 고정issuer,audience를 강제kid미스에 음수 캐시를 두어 재시도 폭주를 줄임
코드 예제 2: Spring Security에서 JWKS 캐시/로테이션을 운영 친화적으로
Spring Security의 NimbusJwtDecoder를 사용할 때도 핵심은 동일합니다.
issuer-uri또는jwk-set-uri를 설정으로 고정- 허용 알고리즘 제한
- 캐시/타임아웃 튜닝
아래는 개념 예시입니다(버전/환경에 따라 API가 조금 다를 수 있습니다).
import org.springframework.context.annotation.Bean;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import java.time.Duration;
@Bean
JwtDecoder jwtDecoder() {
String jwkSetUri = "https://idp.example.com/.well-known/jwks.json";
NimbusJwtDecoder decoder = NimbusJwtDecoder
.withJwkSetUri(jwkSetUri)
.jwsAlgorithm(org.springframework.security.oauth2.jose.jws.SignatureAlgorithm.RS256)
.build();
// issuer/audience 검증은 JwtValidators로 추가 구성하는 것이 일반적
// (여기서는 지면상 생략)
return decoder;
}
Spring에서 자주 겪는 문제는 “키 로테이션 직후 kid 불일치로 401”인데, 이때는 캐시 갱신 타이밍/전파 지연/다중 인스턴스 간 편차가 원인인 경우가 많습니다. 실전 트러블슈팅 흐름은 아래 글이 매우 구체적입니다.
운영 관점: 관측(Observability)과 방어선 설계
1) 메트릭을 “kid 미스” 중심으로 잡아라
다음 지표는 사고를 빨리 찾는 데 도움이 됩니다.
jwt_verify_fail_total{reason="kid_not_found"}jwks_fetch_total{issuer=...}및 실패율- JWKS fetch latency p95/p99
- 인증 미들웨어 CPU 사용률
kid 미스가 급증하면 공격(또는 IdP 로테이션/배포 이슈) 가능성이 큽니다.
2) 레이트 리밋은 인증 “이전”에 두기 어렵다
JWT는 보통 API Gateway나 애플리케이션 레이어에서 검증합니다. 그런데 kid 랜덤 공격은 “검증 로직 자체”를 태우는 게 목적이라, 다음을 함께 고려해야 합니다.
- L7 레이트 리밋(경로/클라이언트/IP 기반)
- 토큰 파싱 단계에서의 빠른 거절(헤더 sanity check)
- JWKS fetch에 대한 회로 차단기(circuit breaker)
3) 키 로테이션은 “겹치는 기간(overlap)”을 가져야 한다
IdP가 키를 바꿀 때 다음이 충족되어야 안전합니다.
- 새 키로 서명 시작
- 기존 키도 일정 기간 JWKS에 함께 노출(검증 측 캐시 전파 지연 고려)
- 충분한 시간 후 기존 키 제거
이 overlap이 없으면 정상 사용자도 401을 맞을 수 있고, 운영팀은 이를 공격으로 오인하기도 합니다.
실무 결론: 안전한 kid 처리의 최소 조건
- JWKS URI는
issuer별로 설정에 고정하고 allowlist로 제한 kid는 길이/문자셋 검증 후 사용(비정상 값은 즉시 거절)- 허용 알고리즘 고정(
RS256등),iss/aud/시간 클레임 검증 필수 kid미스에 대한 JWKS 재조회는 쿨다운/음수 캐시/동시성 제어로 폭주 방지- 키 로테이션은 overlap을 전제로 운영하고,
kid_not_found메트릭을 상시 관측
kid는 “키를 찾기 위한 힌트”일 뿐이고, 그 힌트를 악용한 공격은 대개 캐시/로테이션/외부 호출 같은 운영 요소를 찌릅니다. 검증 로직을 단단히 하는 것과 동시에, 캐시 정책과 관측 지표까지 함께 설계해야 실제 서비스에서 안전합니다.