- Published on
JWT 검증 실패 - JWKS kid 불일치·캐시 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 JWT signature verification failed, invalid_token, No matching key(s) found 같은 오류가 터질 때, 원인의 80%는 JWKS(JSON Web Key Set)에서 kid가 맞는 공개키를 못 찾는 문제이거나, 그 키를 가져오는 과정에서의 캐시/동기화 문제입니다.
특히 IdP(Okta, Auth0, Cognito, Keycloak, Azure AD 등)가 키를 회전(rotation)하는 순간, 애플리케이션은 “새 토큰의 kid”를 보지만 “옛 JWKS 캐시”만 들고 있어 검증에 실패합니다. 이 글은 실무에서 자주 만나는 kid 불일치·캐시 이슈 7가지를 증상 → 원인 → 확인 방법 → 해결책 순서로 정리합니다.
> Spring Security에서 401/invalid_token 전반을 함께 다룬 글은 Spring Security OAuth2 로그인 401·invalid_token 해결도 참고하면 좋습니다.
0) 기본: kid, JWKS, 캐시가 어떻게 엮이나
- JWT 헤더에는 보통 다음이 있습니다.
alg: 서명 알고리즘(예: RS256)kid: 키 식별자(어떤 공개키로 검증해야 하는지 힌트)
- 리소스 서버는 JWKS 엔드포인트(예:
https://issuer/.well-known/jwks.json)에서 공개키 목록을 받아옵니다. - 검증 시 흐름은 대략 이렇습니다.
- 토큰 헤더의
kid를 읽는다 - JWKS에서 같은
kid의 JWK를 찾는다 - 해당 공개키로 서명을 검증한다
- 토큰 헤더의
문제는 2)에서 찾지 못하는 경우입니다. 이때는 “정말로 JWKS에 키가 없는지”와 “있는데 우리 서버가 오래된 JWKS를 보고 있는지(캐시)”를 분리해서 봐야 합니다.
빠른 확인용 커맨드
# 1) 토큰 헤더에서 kid 확인 (JWT는 '.'로 분리)
TOKEN='eyJ...'
echo "$TOKEN" | cut -d. -f1 | base64 -d 2>/dev/null | jq
# 2) JWKS에서 kid 목록 확인
JWKS_URL='https://issuer.example.com/.well-known/jwks.json'
curl -s "$JWKS_URL" | jq -r '.keys[].kid'
# 3) 특정 kid가 존재하는지
KID='abc123'
curl -s "$JWKS_URL" | jq -e --arg KID "$KID" '.keys[] | select(.kid==$KID)'
1) 케이스 1: IdP 키 회전 직후, 서버가 JWKS를 캐시해 둔 경우
증상
- 특정 시점 이후 발급된 토큰만 401
- 로그에
kid not found,No matching key(s) found가 찍힘 - 몇 분~몇 시간 후 자연히 “어느 순간” 해결되기도 함
원인
Spring Security/Nimbus는 JWKS를 매 요청마다 새로 가져오지 않고, 내부적으로 캐시합니다. IdP가 키를 회전해 JWKS가 바뀌었는데, 서버는 캐시된 JWKS를 계속 사용하면 새 kid를 찾지 못합니다.
확인 방법
- 서버 재기동 시 바로 해결되면 캐시 가능성이 큼
- 동일 토큰을 다른 인스턴스에서는 통과하는데 특정 인스턴스에서만 실패하면, 인스턴스별 캐시 불일치 가능성
해결책
- 짧은 TTL로 JWKS 캐시를 재검증하거나, kid 불일치 시 즉시 JWKS 재조회가 일어나도록 설정/구현합니다.
- Spring Security에서 기본 동작이 환경에 따라 부족할 수 있으므로,
NimbusJwtDecoder를 커스터마이징해 JWK 캐시 정책을 명시하는 방식을 고려합니다.
예시(개념 코드):
@Bean
JwtDecoder jwtDecoder() {
String jwkSetUri = "https://issuer.example.com/.well-known/jwks.json";
// Spring이 내부적으로 Nimbus를 사용하므로, 필요 시 JWKSetCache를 명시적으로 구성하는 패턴
// (프로젝트/버전에 따라 API가 다를 수 있어, 핵심은 'kid miss -> refresh' 정책을 갖추는 것)
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri)
.jwsAlgorithm(SignatureAlgorithm.RS256)
.build();
return decoder;
}
> 운영에서는 “kid miss가 나면 JWKS를 강제 리프레시하고 재시도”하는 로직이 가장 체감 효과가 큽니다. 단, 리프레시 폭주를 막기 위해 rate limit/락이 필요합니다(아래 케이스 6 참고).
2) 케이스 2: JWKS URL(issuer) 자체가 잘못되었거나 환경별로 다름
증상
- 모든 토큰이 실패
Invalid issuer,Unable to resolve the Configuration with the provided Issuer또는kid not found가 혼재
원인
issuer-uri가 dev/stage/prod에서 다르게 구성되어야 하는데, 한쪽이 잘못 배포됨- 멀티 테넌트(IdP 테넌트/realm)에서 issuer가 바뀌었는데 설정이 고정됨
- 프록시/게이트웨이가 issuer를 재작성하여 discovery 문서와 실제 토큰의
iss가 불일치
확인 방법
- 토큰 payload의
iss를 디코딩해 확인
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq -r '.iss'
- discovery 문서 확인
ISSUER='https://issuer.example.com'
curl -s "$ISSUER/.well-known/openid-configuration" | jq -r '.issuer, .jwks_uri'
해결책
- Spring 설정에서
spring.security.oauth2.resourceserver.jwt.issuer-uri를 토큰의iss와 정확히 일치시키고, 가능하면jwk-set-uri를 수동 지정하기보다 discovery 기반으로 일관되게 맞춥니다.
3) 케이스 3: 알고리즘(alg) 불일치 또는 잘못된 검증 모드(HS256 vs RS256)
증상
kid는 존재하는데도Invalid signature또는Another algorithm expected류 오류
원인
- IdP는 RS256인데 서버가 HS256(공유 시크릿)로 검증하려고 함
- 반대로, 내부 서비스 토큰은 HS256인데 외부 IdP처럼 RS256로 검증하려고 함
- 보안상 이유로
alg=none차단 등 정책 충돌
확인 방법
JWT 헤더의 alg 확인:
echo "$TOKEN" | cut -d. -f1 | base64 -d 2>/dev/null | jq -r '.alg'
해결책
- 리소스 서버는 IdP의 서명 정책에 맞춰 검증기를 분리합니다.
- 여러 발급자를 동시에 받는다면 issuer별로 decoder를 라우팅합니다.
4) 케이스 4: 프록시/방화벽/네트워크로 JWKS 갱신이 실패하고 오래된 키를 씀
증상
- 평소엔 정상인데 간헐적으로 특정 구간에서만 실패
- 재시도하면 성공하기도 함
- 로그에
Connection timeout,Read timed out,SSLHandshakeException등이 함께 보임
원인
- 서버가 JWKS 엔드포인트로 나가는 egress가 불안정
- 회사 프록시가 TLS를 가로채 JWKS 응답을 캐시하거나 변조
- DNS 장애로 다른 POP/리전에 붙어 일시적으로 다른 JWKS를 받음(드물지만 가능)
확인 방법
- 서버에서 직접 JWKS URL을 curl로 호출해 지연/실패 여부 확인
- 응답 헤더의
Cache-Control,Age,ETag확인
curl -sv "$JWKS_URL" -o /dev/null 2>&1 | sed -n '1,25p'
해결책
- JWKS 호출에 대한 타임아웃/재시도/서킷브레이커를 명시
- 프록시 환경이면 JVM truststore/프록시 설정을 명확히
- 가능하면 IdP의 권장 엔드포인트(지역 고정/전용 도메인)를 사용
5) 케이스 5: CDN/리버스 프록시가 JWKS를 과도하게 캐시(혹은 잘못 캐시)
증상
- IdP에서는 이미 새 키를 공개했는데, 우리 쪽에서 보는 JWKS는 계속 예전 버전
- 여러 인스턴스/지역에서 결과가 다름
원인
- 중간 계층(CDN, API Gateway, Nginx)이
jwks.json을 강하게 캐시 Cache-Control을 무시하거나, 기본 TTL이 길게 잡힘
확인 방법
- JWKS 응답의
Age,Via,X-Cache같은 헤더로 캐시 경유 여부 확인 - 같은 URL을 다른 네트워크(로컬/서버)에서 호출해 내용이 같은지 비교
해결책
- JWKS는 되도록 원본(IdP)에서 직접 가져오게 하고, 중간 캐시를 끄거나 TTL을 짧게
- 내부 프록시를 써야 한다면,
kid miss시 캐시 무시(강제 revalidate) 경로를 마련
6) 케이스 6: kid 불일치 발생 시 “동시 갱신 폭주(Thundering Herd)”로 더 큰 장애
증상
- 키 회전 시점에 401이 늘어나는 걸 넘어서, CPU/스레드/네트워크가 튀고 전체 지연 증가
- JWKS 엔드포인트 호출이 순간적으로 폭증
원인
- 다수 요청이 동시에
kid not found를 만나면, 모든 요청이 JWKS를 갱신하려고 달려듦 - 캐시 갱신에 락이 없거나, 인스턴스 수가 많아 “인스턴스 × 트래픽”만큼 폭주
해결책
- 단일 플라이트(single-flight) 락: 한 번만 갱신하고 나머지는 결과를 공유
- 백오프: 갱신 실패 시 즉시 재시도하지 말고 지수 백오프
- 사전 워밍: 배포/기동 시 JWKS를 미리 로드
개념 코드(동시 갱신 방지):
public class JwksRefresher {
private final ReentrantLock lock = new ReentrantLock();
private volatile Instant lastRefresh = Instant.EPOCH;
public void refreshIfNeeded() {
// 너무 자주 갱신하지 않도록 최소 간격
if (Duration.between(lastRefresh, Instant.now()).toSeconds() < 5) return;
if (lock.tryLock()) {
try {
// double-check
if (Duration.between(lastRefresh, Instant.now()).toSeconds() < 5) return;
// TODO: JWKS fetch + cache update
lastRefresh = Instant.now();
} finally {
lock.unlock();
}
}
}
}
캐시가 “문제의 원인”이기도 하지만, “폭주를 막는 해결책”이기도 합니다. 캐시 전략을 잘못 잡으면 키 회전 한 번에 장애가 커집니다.
7) 케이스 7: 멀티 발급자/멀티 테넌트에서 kid 충돌 또는 잘못된 키셋 선택
증상
- A 테넌트 토큰은 잘 되는데 B 테넌트 토큰만 실패
- 또는 특정 클라이언트/앱에서 발급된 토큰만 실패
원인
- issuer별로 JWKS가 다른데, 서버가 하나의 decoder/JWKS만 보고 검증
- 드물게는 서로 다른 issuer가 같은
kid값을 쓸 수 있는데(전역 유일 보장 없음), 잘못된 키셋을 선택하면 서명 검증 실패
해결책
- 토큰의
iss를 기준으로 decoder를 라우팅(issuer별 JWKS 캐시 분리) - Spring Security에서는
JwtIssuerAuthenticationManagerResolver패턴을 고려
운영 로그/모니터링 체크리스트
- 에러를 “kid not found”와 “signature invalid”로 분리해 집계
- JWKS fetch 성공/실패율, 지연시간, 응답 크기, HTTP 캐시 헤더를 메트릭화
- 키 회전 이벤트(IdP 로그)와 401 스파이크 시간을 상관분석
- 인스턴스별로 실패율이 갈리면 “캐시 불일치/네트워크” 가능성이 큼
Spring Security에서 재현/진단을 빠르게 하는 팁
org.springframework.security및com.nimbusds로거 레벨을 일시적으로 올려, 어떤 issuer/jwks_uri를 보고 있는지 확인합니다.
logging:
level:
org.springframework.security: DEBUG
com.nimbusds: DEBUG
- 하지만 운영에서 DEBUG는 민감정보/로그 폭증 위험이 있으니, 기간/대상을 제한하세요.
마무리: “kid 불일치”는 대부분 캐시·경로·동기화 문제다
정리하면, JWKS kid 불일치로 인한 JWT 검증 실패는 단순히 “키가 없다”가 아니라:
- 키 회전 + 캐시 TTL
- issuer/jwks_uri 설정 오류
- alg/검증 모드 불일치
- 네트워크로 JWKS 갱신 실패
- 중간 캐시(CDN/프록시)의 잘못된 캐싱
- kid miss 시 동시 갱신 폭주
- 멀티 issuer에서 키셋 선택 오류
이 7가지로 대부분 설명됩니다.
추가로, 인증/인가 실패가 401로 뭉뚱그려 보일 때는 Spring Security OAuth2 로그인 401·invalid_token 해결에 있는 체크리스트(issuer/audience/clock skew 등)까지 함께 보면, 원인 분리가 훨씬 빨라집니다.