- Published on
JWT kid 헤더 공격 막기 - JWK 캐시·검증 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 JWT를 검증할 때 kid 헤더는 편리하지만, 검증 설계를 조금만 잘못하면 공격자가 kid를 조작해 잘못된 키로 서명을 통과시키거나(키 혼동), 원격 JWK 조회를 유도해 SSRF/DoS로 이어지게 만들 수 있습니다. 이번 글은 kid 기반 JWK 선택 로직을 안전하게 만들기 위한 검증 규칙, JWK 캐시, 키 회전 운영을 “실전 체크리스트 + 코드”로 정리합니다.
관련 배경(키 혼동 취약점 자체의 메커니즘)은 아래 글에서 더 깊게 다뤘습니다.
왜 kid가 공격 표면이 되는가
JWT 헤더의 kid는 “이 토큰을 검증할 때 어떤 키를 쓰면 되는지”를 가리키는 힌트입니다. 문제는 많은 구현이 이 힌트를 신뢰 가능한 식별자로 취급한다는 점입니다.
대표적인 실패 패턴은 다음과 같습니다.
kid로 원격 URL을 구성해 JWK를 가져옴- 예:
https://keys.example.com/{kid}.json같은 형태 - 공격자가
kid를 조작하면 원격 호출이 폭증하거나, 내부망 주소로 유도되는 SSRF가 됩니다.
- 예:
kid만 보고 키를 고르고, 나머지 조건을 검증하지 않음alg,kty,use,key_ops,iss,aud등의 제약이 빠지면 키 혼동/알고리즘 혼동으로 이어집니다.
키셋(JWKS) 캐시가 없거나, TTL/무효화가 부재
- 매 요청마다 JWKS를 가져오면 DoS에 취약합니다.
- 반대로 TTL이 너무 길고 회전 대응이 없으면 정상 토큰이 실패하거나, 폐기된 키가 오래 남습니다.
핵심은 간단합니다. kid는 “선택 힌트”일 뿐 “신뢰 근거”가 아니며, 키 선택은 반드시 “신뢰 경계(issuer) 내부의 고정된 키셋” 안에서만 일어나야 합니다.
방어 전략 개요: 안전한 JWK 선택 파이프라인
권장 검증 파이프라인은 다음 순서로 구성합니다.
토큰 파싱(헤더/페이로드) 후, 최소 검증
alg가 허용 목록인지typ가 기대값인지(선택)
iss로 “키셋 출처”를 고정- 멀티 테넌트/멀티 IdP면
iss별로 JWKS URI를 고정 매핑
- 멀티 테넌트/멀티 IdP면
JWKS는 “사전 등록된 URI”에서만 가져오고 캐시
kid로 URI를 만들지 않기
키 선택은
kid+ 제약조건(kty,use,alg)으로 필터서명 검증 후 클레임 검증
iss,aud,exp,nbf,iat(정책에 따라),jti(재사용 방지 필요 시)
실패 시 재시도 정책
kid미스매치 또는 새 키 회전 가능성이 있으면 “한 번만” JWKS 강제 갱신 후 재검증
이제 각 단계에서 실전적으로 무엇을 막아야 하는지, 캐시/회전은 어떻게 운영해야 하는지 살펴보겠습니다.
1) kid 입력값을 절대 “경로/쿼리”로 쓰지 말기
가장 중요한 규칙입니다.
- 금지:
kid를 URL, 파일 경로, DB 쿼리의 일부로 조립 - 허용:
kid는 “이미 받아온 JWKS 내부에서 키를 찾는 인덱스”로만 사용
또한 kid는 공격자가 제어하는 문자열이므로, 로깅/메트릭에 넣을 때도 길이 제한을 두고(예: 128자), 제어문자 제거 등 기본 위생 처리가 좋습니다.
2) JWKS 캐시 설계: TTL, 갱신, 동시성
캐시가 필요한 이유
- 네트워크 호출 비용 절감
- JWKS 엔드포인트 장애 전파 차단
- 매 요청 원격 호출로 인한 DoS 완화
권장 캐시 정책(실전)
기본 TTL: 5분~1시간 사이(환경에 따라)
- 키 회전이 잦으면 짧게, 안정적이면 길게
Stale-While-Revalidate(SWR)
- TTL이 지나도 즉시 실패하지 않고, “일단 오래된 캐시로 검증 시도” 후 백그라운드 갱신
- 인증은 경로상 지연에 민감하므로 SWR이 체감 성능에 유리
실패 시 단발성 강제 갱신
- 서명 검증 실패의 원인이 “새 키로 회전”일 수 있으므로,
kid가 캐시에 없거나 검증 실패 시 1회에 한해 JWKS를 즉시 갱신하고 재검증
Negative cache(선택)
- 존재하지 않는
kid에 대해 매번 JWKS를 갱신하지 않도록, - “최근 N분 동안 없던
kid목록”을 짧게 캐시
- 존재하지 않는
동시성 제어(singleflight)
- 캐시 만료 시점에 트래픽이 몰리면 동시 갱신 폭주가 생깁니다.
- 같은
iss의 JWKS 갱신은 한 번만 수행하고 나머지는 기다리거나 stale 캐시 사용
3) 키 선택 규칙: kid만으로 고르지 말고 교차검증
JWKS에는 여러 키가 있을 수 있습니다. 안전한 선택 규칙은 아래 조건을 동시에 만족하는 키만 후보로 둡니다.
kid일치kty일치(예:RSA또는EC)use가sig이거나key_ops에verify포함alg가 토큰 헤더의alg와 일치하거나, 최소한 “허용 조합”에 속함
추가로, 토큰 헤더의 alg 자체도 허용 목록으로 제한해야 합니다. 특히 none은 무조건 거부하고, 서비스가 RS256만 쓴다면 ES256이나 HS256도 거부하는 식으로 좁히는 게 안전합니다.
4) 멀티 issuer 환경: iss별 JWKS URI를 고정 매핑
실무에서 흔한 사고가 “어떤 iss든 토큰에 들어있는 값으로 JWKS URI를 찾는다”입니다. 이 경우 공격자가 임의 issuer를 넣고 자기 JWKS로 검증을 통과시키는 형태로 확장될 수 있습니다.
권장 방식:
- 애플리케이션 설정에
issuer목록을 고정 등록 - 각
issuer에 대해jwks_uri도 고정 등록 - 런타임에는
iss가 등록된 값인지 확인 후, 해당 issuer의 캐시를 사용
5) Java(Spring) 예시: JWKS 캐시 + 단발성 강제 갱신
아래 코드는 “구조”를 보여주기 위한 예시입니다. 실제 프로덕션에서는 검증 라이브러리(Nimbus, jose4j 등)의 안전 옵션을 적극 활용하세요.
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;
// 개념 예시: issuer별 JWKS 캐시
public final class JwksCache {
public static final class Entry {
public final String jwksJson;
public final Instant fetchedAt;
public Entry(String jwksJson, Instant fetchedAt) {
this.jwksJson = jwksJson;
this.fetchedAt = fetchedAt;
}
}
private final Map<String, Entry> cache = new ConcurrentHashMap<>();
private final Map<String, ReentrantLock> locks = new ConcurrentHashMap<>();
private final Duration ttl = Duration.ofMinutes(10);
private final Duration staleTtl = Duration.ofHours(6); // SWR: 최대 허용 stale
public Entry getOrFetch(String issuer, JwksFetcher fetcher) {
Entry e = cache.get(issuer);
if (e != null && !isExpired(e)) {
return e;
}
// singleflight: issuer별 갱신은 1개만
ReentrantLock lock = locks.computeIfAbsent(issuer, k -> new ReentrantLock());
lock.lock();
try {
Entry again = cache.get(issuer);
if (again != null && !isExpired(again)) {
return again;
}
// 만료됐더라도 stale 허용 범위면 우선 반환 + 백그라운드 갱신 같은 패턴도 가능
// 여기서는 단순화를 위해 동기 갱신
String jwksJson = fetcher.fetch(issuer);
Entry fresh = new Entry(jwksJson, Instant.now());
cache.put(issuer, fresh);
return fresh;
} finally {
lock.unlock();
}
}
public Optional<Entry> getStaleIfAllowed(String issuer) {
Entry e = cache.get(issuer);
if (e == null) return Optional.empty();
Instant now = Instant.now();
if (now.isBefore(e.fetchedAt.plus(staleTtl))) {
return Optional.of(e);
}
return Optional.empty();
}
public void forceRefresh(String issuer, JwksFetcher fetcher) {
String jwksJson = fetcher.fetch(issuer);
cache.put(issuer, new Entry(jwksJson, Instant.now()));
}
private boolean isExpired(Entry e) {
return Instant.now().isAfter(e.fetchedAt.plus(ttl));
}
public interface JwksFetcher {
String fetch(String issuer);
}
}
이 캐시를 JWT 검증 로직에 붙일 때는 다음 흐름을 추천합니다.
- 먼저 캐시된 JWKS로 검증 시도
- 실패 원인이
kid미존재 또는 서명 불일치이고, 아직 강제 갱신을 안 했다면 1회 강제 갱신 후 재시도 - 그래도 실패하면 401
public final class JwtVerifier {
private final JwksCache jwksCache;
private final Map<String, String> issuerToJwksUri; // issuer별 고정 매핑
public JwtVerifier(JwksCache jwksCache, Map<String, String> issuerToJwksUri) {
this.jwksCache = jwksCache;
this.issuerToJwksUri = issuerToJwksUri;
}
public VerifiedClaims verify(String jwt) {
ParsedJwt p = ParsedJwt.parse(jwt);
// 1) alg allowlist
if (!p.alg().equals("RS256")) {
throw new Unauthorized("alg not allowed");
}
// 2) issuer allowlist
String iss = p.issuer();
String jwksUri = issuerToJwksUri.get(iss);
if (jwksUri == null) {
throw new Unauthorized("unknown issuer");
}
// 3) 캐시로 검증 시도
JwksCache.Entry entry = jwksCache.getOrFetch(iss, (issuer) -> HttpJwksFetcher.fetch(jwksUri));
boolean ok = Crypto.verifyWithJwks(entry.jwksJson, p);
if (ok) {
return ClaimsValidator.validate(p);
}
// 4) 키 회전 대응: 1회 강제 갱신 후 재검증
jwksCache.forceRefresh(iss, (issuer) -> HttpJwksFetcher.fetch(jwksUri));
JwksCache.Entry refreshed = jwksCache.getOrFetch(iss, (issuer) -> HttpJwksFetcher.fetch(jwksUri));
boolean ok2 = Crypto.verifyWithJwks(refreshed.jwksJson, p);
if (!ok2) {
throw new Unauthorized("signature invalid");
}
return ClaimsValidator.validate(p);
}
// 아래 타입들은 예시용
public record VerifiedClaims(String sub, String aud) {}
public static final class Unauthorized extends RuntimeException {
public Unauthorized(String m) { super(m); }
}
}
위 코드에서 중요한 점은 kid가 어디에도 “네트워크 경로 구성”에 쓰이지 않는다는 점입니다. kid는 오직 Crypto.verifyWithJwks 내부에서, 이미 확보한 JWKS 안에서 키를 고르는 용도여야 합니다.
6) Node.js 예시: JWKS 캐시 + singleflight
Node 환경에서는 jose 라이브러리를 많이 씁니다. 아래는 개념적으로 “issuer별 JWKS를 캐시하고, 만료 시 갱신을 단일화”하는 패턴입니다.
import { jwtVerify, createRemoteJWKSet } from 'jose'
// issuer allowlist + jwksUri 고정
const issuers = new Map([
['https://idp.example.com', 'https://idp.example.com/.well-known/jwks.json'],
])
// issuer별 RemoteJWKSet 캐시
const jwkSetByIssuer = new Map()
function getJwks(iss) {
const jwksUri = issuers.get(iss)
if (!jwksUri) throw new Error('unknown issuer')
let jwks = jwkSetByIssuer.get(iss)
if (!jwks) {
jwks = createRemoteJWKSet(new URL(jwksUri))
jwkSetByIssuer.set(iss, jwks)
}
return jwks
}
export async function verifyAccessToken(token) {
// 헤더/페이로드에서 iss를 먼저 읽고 싶은 유혹이 있지만,
// 실제로는 라이브러리의 안전한 파싱/검증 흐름을 따르는 편이 낫습니다.
// 여기서는 예시로 "issuer는 검증 결과의 payload.iss로 확인" 패턴을 사용합니다.
// 1차 검증은 임시로 모든 issuer를 허용할 수 없으니,
// 보통은 라우팅 레벨에서 issuer를 결정하거나, 테넌트별 엔드포인트를 분리합니다.
// 예시: 단일 issuer만 쓴다는 가정
const iss = 'https://idp.example.com'
const JWKS = getJwks(iss)
const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
issuer: iss,
audience: 'api://my-service',
algorithms: ['RS256'],
})
// 추가 정책 검증
if (typeof payload.sub !== 'string') throw new Error('invalid sub')
return { sub: payload.sub, kid: protectedHeader.kid }
}
포인트는 다음과 같습니다.
algorithms로 허용 알고리즘을 고정issuer,audience를 강제createRemoteJWKSet는 내부적으로 캐시/재사용을 제공합니다. 다만 “issuer별로 분리된 캐시”가 되도록 맵에 저장해 재사용하는 게 중요합니다.
7) 운영에서 자주 터지는 이슈와 해결책
이슈 A: 키 회전 직후 401이 폭증
원인:
- 캐시 TTL이 너무 길고, 강제 갱신 재시도 로직이 없음
해결:
- “검증 실패 시 1회 JWKS 강제 갱신” 도입
- IdP 키 회전 정책에 맞춘 TTL 조정
- 가능하면
Cache-Control헤더를 존중해 TTL을 동적으로 설정
이슈 B: JWKS 엔드포인트 장애가 인증 장애로 전파
원인:
- 캐시가 없거나 TTL이 지나면 무조건 원격 호출
해결:
- SWR 전략으로 stale 캐시를 제한적으로 허용
- 회전이 잦지 않다면
staleTtl을 길게 두고, 장애 시에도 일정 시간 버티게 구성
이슈 C: kid가 너무 많아져 메모리/로그 폭주
원인:
- 공격자가 랜덤
kid를 넣어 미스매치 유도
해결:
kid길이 제한- 미존재
kid에 대한 negative cache(짧은 TTL) - 관측 지표에
kid를 그대로 라벨로 쓰지 말기(카디널리티 폭발)
관측/트러블슈팅 관점에서는 “자원 고갈”이 실제 장애로 이어지는 경우가 많습니다. 인증 계층도 결국 네트워크/스레드/커넥션을 쓰는 컴포넌트이므로, 고갈 징후를 빠르게 잡는 운영 습관이 중요합니다. 비슷한 결의 고갈 트러블슈팅 글로는 아래 글도 참고할 만합니다.
8) 체크리스트: kid 안전 검증의 최소 요건
아래 항목 중 하나라도 빠지면, kid는 “편의 기능”이 아니라 “공격 트리거”가 됩니다.
alg허용 목록 고정(서비스가 쓰는 알고리즘만)iss허용 목록 고정,iss별jwks_uri고정 매핑kid로 원격 리소스 경로를 만들지 않음- JWKS 캐시 도입(요청마다 원격 호출 금지)
- 키 선택 시
kid뿐 아니라kty/use/key_ops/alg교차검증 - 키 회전 대응: 실패 시 1회 강제 갱신 후 재검증
- 관측: JWKS fetch 횟수, 캐시 히트율,
kid not found비율, 검증 실패 원인 분류
마무리: kid는 힌트, 신뢰는 설정에서
kid는 설계상 “토큰 발급자가 넣어주는 힌트”이지, 검증자가 신뢰할 근거가 아닙니다. 신뢰는 언제나 서버 설정(허용 issuer, 고정 JWKS URI, 허용 알고리즘)에서 출발해야 합니다. 여기에 캐시와 회전 대응을 더하면, kid를 악용한 공격(키 혼동, 원격 조회 유도, DoS)을 현실적으로 막으면서도 성능과 가용성을 함께 챙길 수 있습니다.
키 혼동 공격 시나리오와 차단 포인트를 더 깊게 확인하고 싶다면 아래 글을 함께 읽어보세요.