Published on

JWT 검증 실패 - JWKS kid 불일치·캐시 7가지

Authors

서버에서 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)에서 공개키 목록을 받아옵니다.
  • 검증 시 흐름은 대략 이렇습니다.
    1. 토큰 헤더의 kid를 읽는다
    2. JWKS에서 같은 kid의 JWK를 찾는다
    3. 해당 공개키로 서명을 검증한다

문제는 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.securitycom.nimbusds 로거 레벨을 일시적으로 올려, 어떤 issuer/jwks_uri를 보고 있는지 확인합니다.
logging:
  level:
    org.springframework.security: DEBUG
    com.nimbusds: DEBUG
  • 하지만 운영에서 DEBUG는 민감정보/로그 폭증 위험이 있으니, 기간/대상을 제한하세요.

마무리: “kid 불일치”는 대부분 캐시·경로·동기화 문제다

정리하면, JWKS kid 불일치로 인한 JWT 검증 실패는 단순히 “키가 없다”가 아니라:

  1. 키 회전 + 캐시 TTL
  2. issuer/jwks_uri 설정 오류
  3. alg/검증 모드 불일치
  4. 네트워크로 JWKS 갱신 실패
  5. 중간 캐시(CDN/프록시)의 잘못된 캐싱
  6. kid miss 시 동시 갱신 폭주
  7. 멀티 issuer에서 키셋 선택 오류

이 7가지로 대부분 설명됩니다.

추가로, 인증/인가 실패가 401로 뭉뚱그려 보일 때는 Spring Security OAuth2 로그인 401·invalid_token 해결에 있는 체크리스트(issuer/audience/clock skew 등)까지 함께 보면, 원인 분리가 훨씬 빨라집니다.