Published on

JWT invalid signature - JWK 회전·캐시 점검법

Authors

서버 로그에 JWT invalid signature가 찍히면 대부분은 "서명이 위조됐다"로 받아들이지만, 운영 환경에서 더 흔한 원인은 정상 토큰인데 검증기가 잘못된 공개키로 검증하는 상황입니다. 특히 OAuth2/OIDC 기반으로 JWKS(JWK Set)에서 공개키를 받아 검증하는 구조라면, 키 회전(rotation)캐시(cache) 가 어긋나면서 간헐 장애가 발생하기 쉽습니다.

이 글은 다음 상황을 전제로 합니다.

  • 발급자(IdP 또는 Authorization Server)가 RS256 같은 비대칭키로 JWT를 서명
  • 리소스 서버(API)가 /.well-known/jwks.json 에서 JWK를 받아 서명 검증
  • 어느 순간부터 일부 요청에서만 invalid signature가 발생 (간헐적)

아래 순서대로 점검하면 원인을 빠르게 좁힐 수 있습니다.

1) invalid signature의 의미를 먼저 분해하기

라이브러리마다 메시지가 비슷하지만, 실제로는 다음 중 하나입니다.

  1. 토큰의 서명 자체가 손상됨(전송 중 잘림, 인코딩 문제)
  2. 검증 알고리즘이 불일치(alg 기대값과 실제값 불일치)
  3. 검증에 사용한 공개키가 토큰 서명에 사용된 개인키와 짝이 아님

운영에서 가장 흔한 케이스는 3번입니다. 이유는 간단합니다.

  • 토큰 헤더의 kid는 특정 키를 가리키는데
  • 검증기 쪽 캐시에 있는 JWK Set이 오래되어 kid에 해당하는 키가 없거나
  • 동일 kid를 잘못 매핑했거나
  • 프록시/게이트웨이/앱 서버가 각각 다른 시점의 JWK를 들고 있음

즉, "서명 검증 실패"는 보안 사고일 수도 있지만, 키 배포/캐시 동기화 문제일 가능성이 훨씬 큽니다.

2) 토큰 헤더의 kid와 JWK의 kid를 1차로 매칭

가장 먼저 할 일은 토큰 헤더를 디코드해서 kid를 확인하는 것입니다.

# JWT 헤더만 확인 (base64url decode)
python - <<'PY'
import base64, json, sys
jwt = sys.argv[1]
header = jwt.split('.')[0]
pad = '=' * (-len(header) % 4)
print(json.dumps(json.loads(base64.urlsafe_b64decode(header + pad)), indent=2))
PY "${JWT}"

출력에서 다음을 봅니다.

  • alg: 예를 들어 RS256
  • kid: 예를 들어 abc123

이제 JWKS에서 같은 kid를 찾습니다.

curl -s https://issuer.example.com/.well-known/jwks.json | jq '.keys[] | {kid, kty, alg, use}'

여기서 확인 포인트는 다음입니다.

  • JWKS에 해당 kid가 존재하는가
  • kty가 기대값(대개 RSA 또는 EC)인가
  • alg 또는 use가 예상과 맞는가(서명용이면 보통 use: sig)

만약 토큰의 kid가 JWKS에 없다면, 원인은 거의 확정입니다.

  • 키가 회전되었는데 검증기가 아직 옛 JWKS를 캐시 중
  • 또는 발급자가 새 키로 서명했는데 JWKS 배포가 지연

3) JWK 회전 시나리오에서 자주 터지는 패턴

3-1) "새 키로 서명 시작"과 "JWKS 배포"의 순서 문제

정상적인 회전 절차는 보통 다음 순서가 안전합니다.

  1. 새 키를 생성
  2. JWKS에 새 공개키를 먼저 추가(기존 키는 유지)
  3. 일정 시간 대기(캐시/전파 시간)
  4. 그 다음부터 새 개인키로 서명
  5. 충분한 유예 후 옛 공개키 제거

반대로,

  • 4번(새 키로 서명)을 먼저 해버리면
  • 검증기는 새 kid를 모르는 상태에서 검증을 시도하게 되어
  • 간헐적으로 invalid signature가 발생합니다.

3-2) kid 재사용

드물지만 치명적인 패턴입니다.

  • 키를 교체하면서 kid를 동일하게 유지
  • 검증기 캐시에는 옛 키가 kid 기준으로 매핑되어 있음

이 경우 검증기는 "키는 있다"고 판단하고 옛 키로 검증을 시도하므로, 결과는 kid not found가 아니라 invalid signature로 나옵니다.

운영 규칙으로 kid는 키 material이 바뀌면 반드시 바뀌어야 합니다.

3-3) 멀티 리전, 멀티 인스턴스에서 캐시 불일치

다음 구조에서 특히 잘 발생합니다.

  • API 서버가 여러 대이고 각자 메모리 캐시로 JWKS를 저장
  • 어떤 서버는 갱신했고, 어떤 서버는 아직 갱신 전

그래서 특정 인스턴스로 라우팅될 때만 오류가 납니다.

4) 캐시 계층을 "끝까지" 추적하기

JWKS는 보통 아래 계층 중 어딘가에서 캐시됩니다.

  • 애플리케이션 라이브러리 내부 캐시(메모리)
  • API Gateway / Reverse proxy 캐시
  • CDN 캐시
  • 사내 egress proxy 캐시

여기서 핵심은 "내가 의도한 TTL"이 아니라, 실제로 적용된 TTL입니다.

4-1) HTTP 캐시 헤더 확인

JWKS 응답의 헤더를 확인합니다.

curl -I https://issuer.example.com/.well-known/jwks.json

다음 헤더들을 봅니다.

  • Cache-Control: max-age=...
  • Age: ...
  • ETag 또는 Last-Modified

문제 패턴:

  • max-age가 지나치게 김(예: 수 시간)
  • Age가 이미 큰 값인데도 중간 캐시가 갱신을 안 함
  • ETag가 바뀌지 않아 갱신 트리거가 안 걸림

4-2) 애플리케이션 캐시 TTL이 JWKS TTL보다 길다

많은 JWT 라이브러리는 "JWKS를 한 번 가져오면 N분 캐시" 같은 기본값이 있습니다.

  • JWKS 서버는 max-age=60으로 1분 캐시를 의도
  • 그런데 앱은 10분 캐시

이러면 회전 직후 9분 동안 간헐 장애가 날 수 있습니다.

4-3) 캐시 무효화 전략

운영 안정성을 위해서는 다음 중 하나가 필요합니다.

  • kid not found가 발생하면 즉시 JWKS를 강제 갱신하고 재검증
  • 백그라운드에서 주기적으로 JWKS를 리프레시

단, 여기서도 주의할 점이 있습니다.

  • 갱신 실패 시(네트워크/DNS) 기존 캐시를 버리면 전체 장애로 번질 수 있음
  • 따라서 "stale-while-revalidate" 같은 전략이 안전합니다

캐시 문제를 다른 영역에서도 자주 겪는다면, 캐시가 실제로 어떻게 동작하는지 점검하는 관점이 도움이 됩니다. 예를 들어 K8s에서 이미지 캐시/인증이 꼬이는 케이스를 다룬 글인 K8s ImagePullBackOff - registry 인증·캐시 진단법도 비슷한 사고 흐름으로 원인을 좁힙니다.

5) 코드로 보는 안전한 JWKS 검증 패턴 (Node.js)

jose 기준으로, createRemoteJWKSet을 사용하면 kid에 따라 원격 JWKS에서 키를 가져오고 캐시합니다.

import { jwtVerify, createRemoteJWKSet } from 'jose'

const issuer = 'https://issuer.example.com'
const jwksUrl = new URL('/.well-known/jwks.json', issuer)

// jose 내부 캐시를 사용 (기본적으로 합리적이지만 운영에 맞게 튜닝 필요)
const JWKS = createRemoteJWKSet(jwksUrl)

export async function verify(token: string) {
  const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
    issuer,
    // audience도 반드시 고정해서 검증
    audience: 'api://my-service',
  })

  return { payload, header: protectedHeader }
}

운영 팁:

  • audience, issuer를 고정 검증하지 않으면 다른 토큰이 섞여 들어와 "서명은 맞는데 다른 발급자" 같은 혼란이 생깁니다.
  • 라이브러리의 JWKS 캐시 TTL, 타임아웃, 리트라이 정책을 문서로 확인하고, IdP의 회전 정책과 맞추세요.

6) Spring Security에서의 점검 포인트

Spring Security Resource Server는 보통 issuer-uri 또는 jwk-set-uri를 설정합니다.

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: "https://issuer.example.com"
          # 또는 jwk-set-uri를 직접 지정
          # jwk-set-uri: "https://issuer.example.com/.well-known/jwks.json"

점검 포인트:

  • issuer-uri를 쓰면 내부적으로 OIDC discovery를 통해 JWKS URI를 찾습니다. discovery 응답과 JWKS 응답이 서로 다른 캐시 정책을 가질 수 있습니다.
  • 인스턴스가 여러 대라면, 특정 파드에서만 실패하는지 확인하세요. 실패 파드의 시각과 JWKS 갱신 시각이 맞물릴 때 패턴이 보입니다.

Spring Security 관련 인증 흐름이 꼬일 때는 리다이렉트/세션 문제로 보이지만 실제로는 설정 불일치인 경우도 많습니다. 인증 설정을 점검하는 관점은 Spring Security OAuth2 로그인 무한 리다이렉트 해결과도 연결됩니다.

7) 로깅과 관측: kid를 반드시 남겨라

invalid signature만 남기면 원인 분석이 어렵습니다. 최소한 다음 필드를 구조화 로그로 남기는 것을 권장합니다.

  • 토큰 헤더의 kid
  • iss, aud
  • 검증에 사용한 JWKS URI
  • JWKS 캐시 상태(캐시 히트 여부, 마지막 갱신 시각)
  • 실패 유형을 구분(kid not found, signature mismatch, alg mismatch 등)

예시(개념 코드):

try {
  const result = await verify(token)
  return result
} catch (e: any) {
  // 토큰 전체를 로그로 남기지 말고, 헤더만 파싱해서 kid 정도만 남긴다
  const kid = (() => {
    try {
      const h = token.split('.')[0]
      const pad = '='.repeat((-h.length) % 4)
      return JSON.parse(Buffer.from(h + pad, 'base64url').toString('utf8')).kid
    } catch {
      return undefined
    }
  })()

  console.error('jwt_verify_failed', {
    reason: e?.code ?? e?.message,
    kid,
  })
  throw e
}

8) 운영 체크리스트 (바로 적용)

8-1) 즉시 확인

  • 같은 kid가 JWKS에 존재하는가
  • kid가 존재하는데도 실패한다면 kid 재사용 여부 의심
  • 실패가 특정 인스턴스/리전에만 발생하는가
  • JWKS 응답 헤더의 Cache-Control, Age가 비정상적으로 큰가

8-2) IdP 회전 정책 점검

  • 새 키를 JWKS에 올리고 충분히 전파된 뒤 서명 전환하는가
  • 옛 키 제거 유예 기간은 토큰 최대 TTL보다 충분히 긴가
  • kid 생성 규칙이 유니크를 보장하는가

8-3) 검증기 캐시 전략 점검

  • 라이브러리 기본 JWKS 캐시 TTL이 무엇인가
  • kid not found 시 강제 갱신 후 재시도하는가
  • JWKS fetch 실패 시 기존 캐시를 유지하는가

9) 결론: invalid signature는 "키 동기화" 문제일 때가 많다

invalid signature가 간헐적으로 발생한다면, 우선 공격을 의심하기 전에 JWK 회전과 캐시 계층을 의심하는 것이 현실적인 출발점입니다.

  • 토큰의 kid와 JWKS의 kid 매칭
  • 회전 절차의 순서(배포 후 전환)
  • 캐시 TTL의 실제 적용값 추적
  • 멀티 인스턴스에서의 불일치 제거

이 네 가지를 잡으면, 대부분의 "갑자기 JWT가 깨졌다" 류 장애는 재발 방지까지 연결됩니다.