- Published on
JWT invalid signature - JWK 회전·캐시 점검법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 로그에 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의 의미를 먼저 분해하기
라이브러리마다 메시지가 비슷하지만, 실제로는 다음 중 하나입니다.
- 토큰의 서명 자체가 손상됨(전송 중 잘림, 인코딩 문제)
- 검증 알고리즘이 불일치(
alg기대값과 실제값 불일치) - 검증에 사용한 공개키가 토큰 서명에 사용된 개인키와 짝이 아님
운영에서 가장 흔한 케이스는 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: 예를 들어RS256kid: 예를 들어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 배포"의 순서 문제
정상적인 회전 절차는 보통 다음 순서가 안전합니다.
- 새 키를 생성
- JWKS에 새 공개키를 먼저 추가(기존 키는 유지)
- 일정 시간 대기(캐시/전파 시간)
- 그 다음부터 새 개인키로 서명
- 충분한 유예 후 옛 공개키 제거
반대로,
- 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가 깨졌다" 류 장애는 재발 방지까지 연결됩니다.