Published on

JWT 검증 실패 - kid 불일치와 JWK 캐시 갱신법

Authors

서론

운영 중인 API가 어느 날부터 간헐적으로 401을 뱉기 시작합니다. 토큰 자체는 만료되지 않았고, 서명 알고리즘도 맞는데 검증 라이브러리 로그에는 흔히 이런 메시지가 따라옵니다.

  • No matching key(s) found for kid ...
  • kid does not match any keys in JWKS
  • Failed to decode JWT: Another algorithm expected, or no key found

이때 많은 팀이 “토큰이 잘못 발급됐나?”부터 의심하지만, 실제로는 IdP(인증 제공자)의 키 롤오버리소스 서버의 JWK(JWKS) 캐시가 엇갈리며 발생하는 경우가 압도적으로 많습니다. 이 글은 kid 불일치가 의미하는 바를 정확히 짚고, **JWK 캐시를 안전하게 갱신하는 실전 패턴(재시도/백오프/동시성 제어)**까지 정리합니다.

관련해서 Spring Security 필터 체인 때문에 401이 반복되는 문제도 종종 같이 보이므로, 필터 순서 이슈가 의심되면 Spring Boot 3에서 401 반복? JWT 필터 순서 정리도 함께 확인해두면 좋습니다.

1) kid 불일치가 발생하는 전형적인 시나리오

JWT 헤더에는 보통 다음과 같은 값이 있습니다.

{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "a1b2c3d4"
}
  • kid(Key ID)는 “이 토큰을 서명한 공개키가 JWKS의 어떤 키인지”를 가리키는 식별자입니다.
  • 리소스 서버는 IdP의 /.well-known/jwks.json(혹은 OIDC discovery의 jwks_uri)에서 공개키 목록을 받아 캐시에 저장하고, 토큰 헤더의 kid와 일치하는 키로 서명을 검증합니다.

여기서 불일치가 나는 대표 케이스는 다음과 같습니다.

1-1. IdP 키 롤오버(회전) 직후

IdP는 보안상 주기적으로 키를 회전합니다.

  • 새 키로 토큰을 발급하기 시작했는데
  • 리소스 서버는 아직 예전 JWKS를 캐시하고 있어
  • 새 토큰의 kid를 모르는 상태

즉, 토큰은 정상이지만 검증기가 “그 kid에 해당하는 공개키를 아직 못 받았다”고 실패합니다.

1-2. 캐시 TTL이 과도하게 길거나, 무효화가 없다

JWKS를 24시간/7일 같은 긴 TTL로 캐시하면, 키 롤오버 후 장애가 길게 지속될 수 있습니다.

1-3. 여러 인스턴스/리전 간 캐시 불일치

  • A 인스턴스는 JWKS를 갱신해서 성공
  • B 인스턴스는 구 캐시로 실패

이때 “간헐적 401”로 관측됩니다.

1-4. 잘못된 jwks_uri, 테넌트/issuer 혼동

  • 멀티 테넌트(IdP realm/tenant)에서 issuer를 잘못 설정
  • dev/prod issuer가 섞임
  • iss는 맞는데 jwks_uri가 다른 환경을 가리키는 구성 실수

이 경우는 캐시 갱신으로 해결되지 않습니다. 먼저 iss, aud, jwks_uri를 정확히 맞춰야 합니다.

2) 원인 진단 체크리스트 (로그/관측 포인트)

운영에서 빠르게 결론 내리려면 “토큰 vs JWKS vs 캐시”를 분리해서 봐야 합니다.

2-1. 토큰에서 kid/iss/aud를 즉시 확인

로컬에서 페이로드를 검증하지 않고 헤더/클레임만 확인해도 방향이 잡힙니다.

# JWT 헤더만 디코드 (서명 검증 X)
python - <<'PY'
import base64, json, sys
jwt = sys.stdin.read().strip()
header = jwt.split('.')[0]
pad = '=' * (-len(header) % 4)
print(json.dumps(json.loads(base64.urlsafe_b64decode(header+pad)), indent=2))
PY <<<'eyJhbGciOiJSUzI1NiIsImtpZCI6ImExYjJjM2Q0In0.XXX.YYY'

확인할 것:

  • kid
  • alg가 기대값(RS256/ES256 등)인지
  • iss, aud가 서버 설정과 일치하는지

2-2. JWKS에 해당 kid가 존재하는지 확인

JWKS_URI='https://idp.example.com/.well-known/jwks.json'
KID='a1b2c3d4'

curl -s "$JWKS_URI" | jq -r --arg kid "$KID" '.keys[] | select(.kid==$kid) | {kid,kty,alg,use,n,e,crv,x,y}'
  • JWKS에 kid가 없다면: IdP 롤오버/배포 지연/issuer 혼동 가능성이 큽니다.
  • JWKS에는 있는데 서버가 못 찾는다면: 서버 캐시/갱신 로직 문제 가능성이 큽니다.

2-3. 관측(메트릭)으로 “kid 미스”를 분리 집계

실무적으로는 401 하나로 뭉뚱그리지 말고, 최소한 아래는 분리하면 MTTR이 확 줄어듭니다.

  • jwt_validation_failed_total{reason="kid_not_found"}
  • jwt_validation_failed_total{reason="signature_invalid"}
  • jwks_refresh_total{result="success|fail"}
  • jwks_cache_age_seconds

3) JWK 캐시 전략: “TTL + 강제 갱신 + 단일 비행(single-flight)”

JWK 캐시는 단순히 “몇 분마다 갱신”이 아니라, 장애를 줄이기 위한 패턴이 필요합니다.

핵심은 세 가지입니다.

  1. 기본 TTL 캐시: 불필요한 JWKS 호출을 줄인다.
  2. kid 미스 시 강제 갱신: 새 키를 즉시 당겨온다.
  3. 동시성 제어(single-flight): 트래픽이 몰릴 때 모든 요청이 동시에 JWKS를 당기지 않게 한다.

추가로, 갱신 실패 시에도 기존 캐시를 즉시 버리지 않는 stale-while-revalidate 성격이 유용합니다(단, 키 폐기/침해 대응 정책과 충돌하지 않게 주의).

4) 구현 예시 1: Spring Boot(Spring Security)에서 kid 미스 시 JWKS 강제 갱신

Spring Security의 NimbusJwtDecoder는 내부적으로 JWK Set을 가져와 캐시합니다. 하지만 “kid 미스 시 즉시 재조회”를 확실히 보장하려면, 아래처럼 JWK 소스(JWKSource)를 커스터마이즈하거나, 최소한 캐시/타임아웃/리트라이를 명시적으로 관리하는 편이 안전합니다.

아래 예시는 개념을 보여주기 위한 “직접 JWKS를 가져와 kid로 키를 선택”하는 방식입니다(운영에서는 검증 라이브러리의 표준 메커니즘을 최대한 활용하되, kid 미스 시 refresh 트리거를 넣는 방향을 권장).

// build.gradle: com.nimbusds:nimbus-jose-jwt

import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.crypto.RSASSAVerifier;
import com.nimbusds.jose.jwk.JWK;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jwt.SignedJWT;

import java.net.URL;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.concurrent.locks.ReentrantLock;

public class JwksCachingVerifier {
    private final URL jwksUrl;
    private final Duration ttl;

    private volatile JWKSet cached;
    private volatile Instant cachedAt = Instant.EPOCH;

    private final ReentrantLock refreshLock = new ReentrantLock();

    public JwksCachingVerifier(URL jwksUrl, Duration ttl) {
        this.jwksUrl = jwksUrl;
        this.ttl = ttl;
    }

    public boolean verify(String jwtString) throws Exception {
        SignedJWT jwt = SignedJWT.parse(jwtString);
        JWSHeader header = jwt.getHeader();
        String kid = header.getKeyID();

        // 1) TTL 만료면 백그라운드/동기 갱신
        if (isExpired()) {
            refreshJwksIfNeeded();
        }

        // 2) 현재 캐시에서 kid 찾기
        Optional<RSAKey> key = findRsaKeyByKid(kid);

        // 3) kid 미스면 강제 갱신 후 1회 재시도
        if (key.isEmpty()) {
            forceRefresh();
            key = findRsaKeyByKid(kid);
        }

        if (key.isEmpty()) {
            throw new IllegalStateException("No matching JWK for kid=" + kid);
        }

        // 4) 알고리즘 검증(alg 혼동 공격 방지)
        if (!JWSAlgorithm.RS256.equals(header.getAlgorithm())) {
            throw new IllegalStateException("Unexpected alg=" + header.getAlgorithm());
        }

        return jwt.verify(new RSASSAVerifier(key.get().toRSAPublicKey()));
    }

    private boolean isExpired() {
        return Instant.now().isAfter(cachedAt.plus(ttl));
    }

    private Optional<RSAKey> findRsaKeyByKid(String kid) {
        JWKSet local = cached;
        if (local == null) return Optional.empty();
        for (JWK jwk : local.getKeys()) {
            if (kid != null && kid.equals(jwk.getKeyID()) && jwk instanceof RSAKey rsa) {
                return Optional.of(rsa);
            }
        }
        return Optional.empty();
    }

    private void refreshJwksIfNeeded() throws Exception {
        if (!refreshLock.tryLock()) return; // single-flight
        try {
            if (!isExpired()) return;
            cached = JWKSet.load(jwksUrl);
            cachedAt = Instant.now();
        } finally {
            refreshLock.unlock();
        }
    }

    private void forceRefresh() throws Exception {
        refreshLock.lock();
        try {
            cached = JWKSet.load(jwksUrl);
            cachedAt = Instant.now();
        } finally {
            refreshLock.unlock();
        }
    }
}

포인트:

  • kid 미스 시 1회 강제 갱신 후 재시도: 키 롤오버 직후 장애 시간을 최소화합니다.
  • single-flight(tryLock): 트래픽 폭주 시 JWKS 엔드포인트를 DDoS처럼 두드리는 상황을 방지합니다.
  • alg를 토큰 헤더에만 의존하지 말고, 서버가 기대하는 알고리즘으로 고정 검증합니다.

5) 구현 예시 2: Node.js(Express)에서 JWKS 캐시 + kid 미스 리프레시

Node 생태계에서는 jwks-rsa 같은 라이브러리를 많이 쓰지만, 여기서는 동작 원리를 명확히 하기 위해 간단 캐시를 직접 구현한 예시를 제시합니다.

import express from "express";
import jwt from "jsonwebtoken";
import fetch from "node-fetch";
import jwkToPem from "jwk-to-pem";

const app = express();

const JWKS_URI = process.env.JWKS_URI;
const ISSUER = process.env.ISSUER;
const AUDIENCE = process.env.AUDIENCE;

const TTL_MS = 5 * 60 * 1000;
let cache = { jwks: null, fetchedAt: 0 };
let refreshing = null; // Promise for single-flight

async function loadJwks() {
  const res = await fetch(JWKS_URI, { timeout: 2000 });
  if (!res.ok) throw new Error(`JWKS fetch failed: ${res.status}`);
  return await res.json();
}

async function refreshIfNeeded(force = false) {
  const now = Date.now();
  const expired = now - cache.fetchedAt > TTL_MS;
  if (!force && cache.jwks && !expired) return;

  if (!refreshing) {
    refreshing = (async () => {
      const jwks = await loadJwks();
      cache = { jwks, fetchedAt: Date.now() };
    })().finally(() => {
      refreshing = null;
    });
  }
  await refreshing;
}

function findJwkByKid(jwks, kid) {
  return jwks?.keys?.find((k) => k.kid === kid);
}

async function verifyJwt(token) {
  const decodedHeader = JSON.parse(Buffer.from(token.split(".")[0], "base64").toString("utf8"));
  const kid = decodedHeader.kid;

  await refreshIfNeeded(false);
  let jwk = findJwkByKid(cache.jwks, kid);

  // kid 미스면 강제 갱신 후 1회 재시도
  if (!jwk) {
    await refreshIfNeeded(true);
    jwk = findJwkByKid(cache.jwks, kid);
  }
  if (!jwk) throw new Error(`No matching JWK for kid=${kid}`);

  const pem = jwkToPem(jwk);
  return jwt.verify(token, pem, {
    algorithms: ["RS256"],
    issuer: ISSUER,
    audience: AUDIENCE,
    clockTolerance: 5,
  });
}

app.get("/private", async (req, res) => {
  try {
    const token = (req.headers.authorization || "").replace("Bearer ", "");
    const claims = await verifyJwt(token);
    res.json({ ok: true, sub: claims.sub });
  } catch (e) {
    res.status(401).json({ ok: false, error: String(e.message || e) });
  }
});

app.listen(3000);

운영 팁:

  • JWKS fetch timeout을 짧게(예: 1~2초) 잡고, 실패하면 기존 캐시로 버티는 전략을 고려하세요.
  • 강제 갱신은 kid 미스에서만 트리거하고, 모든 401에서 트리거하지 마세요(불필요한 JWKS 폭주 방지).

6) 캐시 갱신 정책 설계: TTL만으로는 부족한 이유

현실의 키 롤오버는 “정각에 바뀌고 모두가 동시에 반영”되지 않습니다.

  • IdP 내부적으로 새 키가 배포되는 동안 일부 엣지/리전에만 노출
  • CDN 캐시로 인해 JWKS 응답이 지연
  • 리소스 서버가 여러 AZ/리전으로 분산

따라서 다음 조합이 가장 실전적입니다.

6-1. 기본 TTL: 5~15분 권장(상황별 조정)

  • 너무 짧으면 JWKS 호출 비용/장애 전파가 커짐
  • 너무 길면 키 롤오버 시 장애 시간이 길어짐

6-2. kid 미스 시 즉시 1회 refresh + 재검증

  • “정상 토큰인데 키를 모르는” 케이스를 빠르게 회복
  • 무한 재시도 금지(1회로 충분)

6-3. single-flight로 동시 갱신 폭주 방지

  • 트래픽이 큰 서비스일수록 필수

6-4. stale-while-revalidate(선택)

  • JWKS 갱신이 실패하더라도, 기존 캐시로 검증을 계속 시도
  • 단, 키 폐기/침해 상황에서는 “오래된 키를 계속 신뢰”하는 것이 위험할 수 있으니 보안 정책과 합의가 필요

7) 운영에서 자주 놓치는 함정 5가지

7-1. alg 혼동 공격 방어

토큰 헤더의 alg를 그대로 신뢰하지 말고, 서버가 허용하는 알고리즘을 고정하세요.

7-2. iss/aud 불일치가 kid 문제처럼 보일 때

검증 라이브러리에 따라 메시지가 애매하게 나올 수 있습니다. 반드시 iss, aud를 함께 로깅/검증하세요.

7-3. JWKS 엔드포인트 접근 장애

EKS/사내망에서 egress가 막히면 JWKS 갱신이 실패하고, 결과적으로 kid 미스가 지속됩니다. 네트워크 관점 점검이 필요하면 EKS에서 Pod는 정상인데 egress만 막힐 때 점검 같은 체크리스트 방식으로 원인을 좁히는 게 빠릅니다.

7-4. CDN/프록시 캐시가 JWKS를 과도 캐싱

Cache-Control을 무시하거나, 중간 프록시가 응답을 오래 캐시하면 롤오버 반영이 늦습니다. JWKS 응답 헤더를 확인하고, 필요하면 프록시 정책을 조정하세요.

7-5. 장애 시 “갱신 루프”로 IdP를 때리는 문제

kid 미스가 발생하면 모든 요청이 JWKS를 갱신하려고 하면 IdP에 부하가 커지고, 결국 더 오래 장애가 지속됩니다. single-flight + 백오프는 사실상 필수입니다.

8) 결론: kid 불일치는 ‘토큰 문제’가 아니라 ‘캐시/롤오버 문제’인 경우가 많다

kid 불일치는 대개 다음 공식을 따릅니다.

  • IdP는 이미 새 키로 서명했고
  • 리소스 서버는 아직 옛 JWKS를 캐시하고 있으며
  • 그 결과로 정상 토큰이 401이 된다

해결의 핵심은 “TTL을 줄이자” 하나로 끝나지 않습니다.

  • TTL 기반 캐시를 두되
  • kid 미스 시 강제 갱신 후 1회 재시도를 넣고
  • single-flight로 갱신 폭주를 방지하면 키 롤오버 구간의 간헐적 장애를 대부분 제거할 수 있습니다.

마지막으로, 401이 단순히 JWT 검증이 아니라 필터 체인/예외 처리에서 증폭되는 경우도 있으니, Spring 기반이라면 Spring Boot 3에서 401 반복? JWT 필터 순서 정리와 함께 점검하면 원인 분리가 더 빨라집니다.