- Published on
Keycloak JWT kid 불일치 401 - JWKS 캐시·회전 대응
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 간 인증이 잘 되다가 어느 순간부터 API가 401을 뱉기 시작하고, 로그에는 kid(Key ID) 불일치나 Unable to find a signing key that matches 'kid' 같은 메시지가 찍히는 경우가 있습니다. Keycloak을 IdP로 쓰는 환경에서 특히 자주 보이는 장애 패턴인데, 대부분은 키 회전(rotation) + JWKS 캐시 조합에서 발생합니다.
이 글에서는 Keycloak의 JWT/JWKS 동작을 빠르게 복기한 뒤, kid 불일치로 인한 401을 재현/진단/해결하는 방법과 운영 환경에서 재발을 줄이는 캐시 갱신 전략, 롤링 배포 시나리오, 라이브러리별 설정 포인트를 정리합니다.
> 참고: 네트워크/인그레스 타임아웃이나 간헐 502/504로 인해 JWKS 갱신이 실패해 증상이 악화되는 경우도 있습니다. 인프라 레벨 튜닝은 Kubernetes LLM 서비스 502 504 간헐 장애와 스트리밍 끊김을 끝내는 NGINX Ingress와 Gunicorn Uvicorn 실전 튜닝도 함께 참고하면 좋습니다.
증상: 왜 kid 불일치가 401로 이어지나
JWT 헤더에는 보통 다음과 같은 값이 들어 있습니다.
{
"alg": "RS256",
"typ": "JWT",
"kid": "nZq2..."
}
리소스 서버(API)는 토큰 서명을 검증하기 위해 Keycloak의 JWKS(JSON Web Key Set) 엔드포인트에서 공개키 목록을 받아옵니다.
- Keycloak OIDC discovery:
https://<kc>/realms/<realm>/.well-known/openid-configuration - JWKS URI(대개):
https://<kc>/realms/<realm>/protocol/openid-connect/certs
검증 로직은 대체로 다음 흐름입니다.
- 토큰 헤더의
kid를 읽는다. - JWKS에서 동일한
kid를 가진 JWK를 찾는다. - 해당 공개키로 서명을 검증한다.
- 성공하면 인증 통과, 실패하면 401.
즉, 토큰이 가리키는 kid가 JWKS에 없으면 검증 자체가 불가능하므로 401이 납니다.
가장 흔한 원인 4가지
1) Keycloak 키 회전 후, API의 JWKS 캐시가 갱신되지 않음
대부분의 JWT 검증 라이브러리는 JWKS를 매 요청마다 가져오지 않고(비효율/장애 유발), 일정 시간 캐시합니다.
- Keycloak에서 새 키가 활성화(active)되었지만
- API는 예전 JWKS를 캐시한 상태라
- 새
kid를 가진 토큰을 받으면 “키를 못 찾음”으로 실패
2) Keycloak 클러스터에서 노드 간 키/캐시 전파 타이밍 이슈
Keycloak를 다중 노드로 운영할 때, 키 회전 직후 특정 노드가 먼저 새 키로 토큰을 발급하고 다른 노드는 아직 예전 JWKS를 제공하는 등 짧은 불일치 구간이 생길 수 있습니다(설정/캐시/스토리지 구성에 따라).
3) 리소스 서버가 잘못된 Realm/Issuer/JWKS URI를 바라봄
다음이 흔한 실수입니다.
issuer검증은 A realm인데, JWKS는 B realm을 조회- 환경변수/설정 분리 미흡으로 staging 키를 prod에서 참조
- 프록시/게이트웨이에서 내부/외부 주소가 섞여 discovery 결과가 어긋남
4) JWKS 갱신 요청 자체가 실패(네트워크, DNS, 타임아웃)
키 회전은 “새 kid 토큰이 오기 시작하는 순간”부터 서비스에 영향을 줍니다. 그런데 그 순간에 JWKS fetch가 타임아웃/차단되면, 401이 폭발적으로 늘어납니다.
EKS 환경이라면 DNS 간헐 장애가 JWKS 갱신 실패로 이어질 수 있어 EKS CoreDNS SERVFAIL·NXDOMAIN 간헐 해결 9가지 같은 체크리스트도 유용합니다.
10분 내 진단 체크리스트
1) 문제가 된 토큰의 kid 확인
JWT를 디코드해서 헤더만 확인합니다(서명 검증 없이 base64 decode).
TOKEN="eyJhbGciOiJSUzI1NiIsImtpZCI6IkF..."
python - <<'PY'
import base64, json, os
h = os.environ['TOKEN'].split('.')[0]
pad = '=' * (-len(h) % 4)
print(json.loads(base64.urlsafe_b64decode(h+pad)))
PY
출력에서 kid 값을 확보합니다.
2) JWKS에서 해당 kid가 존재하는지 확인
KC="https://keycloak.example.com"
REALM="myrealm"
KID="<토큰에서 추출한 kid>"
curl -s "$KC/realms/$REALM/protocol/openid-connect/certs" \
| python - <<'PY'
import json,sys
jwks=json.load(sys.stdin)
print([k.get('kid') for k in jwks.get('keys',[])])
PY
- 목록에
kid가 없으면: 캐시/회전/realm 불일치 가능성이 큽니다. - 목록에
kid가 있는데도 401: issuer/audience/clock skew 등 다른 검증 실패일 수 있습니다.
3) discovery의 issuer와 API 설정의 issuer가 일치하는지 확인
curl -s "$KC/realms/$REALM/.well-known/openid-configuration" | jq -r '.issuer, .jwks_uri'
API가 검증하는 issuer가 위 issuer와 문자 단위로 동일해야 합니다(슬래시 하나 차이도 실패 원인이 됩니다).
해결 전략: “kid miss → 즉시 JWKS 재조회” 패턴
핵심은 간단합니다.
- 평소에는 JWKS를 캐시해서 성능/안정성을 확보하되
- 검증 중
kid를 못 찾는 경우에 한해 JWKS를 강제 갱신하고 - 그래도 없으면 401을 반환
이 패턴이 없으면, 키 회전 직후 캐시 TTL 동안 서비스가 계속 401을 낼 수 있습니다.
아래는 언어/프레임워크별로 자주 쓰는 구현 포인트입니다.
Node.js 예시 (jose + 원격 JWKS, kid miss 시 재시도)
jose의 createRemoteJWKSet은 내부적으로 캐시를 가지며, 일반적으로는 충분합니다. 다만 “kid miss 시 강제 재조회”를 명확히 하고 싶다면, 검증 실패 사유를 구분해 1회 재시도하는 방식을 씁니다.
import { jwtVerify, createRemoteJWKSet, errors } from 'jose'
const issuer = 'https://keycloak.example.com/realms/myrealm'
const jwksUri = new URL(`${issuer}/protocol/openid-connect/certs`)
// 기본 캐시가 있지만, 런타임/환경에 따라 튜닝 포인트가 필요할 수 있음
const JWKS = createRemoteJWKSet(jwksUri)
export async function verifyAccessToken(token) {
try {
return await jwtVerify(token, JWKS, {
issuer,
// audience가 있다면 반드시 지정
// audience: 'my-client-id',
})
} catch (e) {
// kid mismatch/키 탐색 실패 계열을 1회 재시도
const isKeyNotFound =
e instanceof errors.JWKSNoMatchingKey ||
(typeof e?.code === 'string' && e.code.includes('ERR_JWKS'))
if (!isKeyNotFound) throw e
// 재시도: createRemoteJWKSet은 내부 캐시를 사용하므로,
// 여기서 "강제" 갱신이 필요하면 JWKS 인스턴스를 새로 만들거나
// 별도 fetch+캐시 레이어를 두는 방식이 더 확실합니다.
const freshJWKS = createRemoteJWKSet(jwksUri)
return await jwtVerify(token, freshJWKS, { issuer })
}
}
운영 팁:
- 재시도는 1회로 제한하세요(무한 재시도는 장애 시 폭발).
- 401이 급증할 때 JWKS 엔드포인트로 트래픽이 몰릴 수 있으니, 백오프/서킷브레이커도 고려합니다.
Spring Security (Resource Server)에서의 포인트
Spring Boot/Spring Security는 NimbusJwtDecoder 기반으로 JWKS를 가져오고 캐시합니다. 대부분은 설정만으로 해결되지만, 키 회전 빈도가 있거나 “kid miss 즉시 갱신”이 필요하면 다음을 점검합니다.
1) issuer 기반 자동 설정을 우선 사용
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://keycloak.example.com/realms/myrealm
jwk-set-uri를 직접 박는 것보다 issuer-uri가 discovery 기반으로 정합성을 맞추기 쉽습니다.
2) 캐시/타임아웃/프록시 환경 점검
- Keycloak가 프록시 뒤에 있을 때
issuer가 내부 주소로 나오지 않도록(Keycloak hostname 설정) 정리 - JWKS fetch 타임아웃이 너무 짧아 갱신 실패가 잦지 않은지 확인
Spring 쪽에서 더 공격적으로 제어하려면, RestOperations/WebClient를 커스터마이징한 NimbusJwtDecoder 구성으로 타임아웃/프록시/DNS 문제를 줄이는 접근을 씁니다.
Keycloak 측 대응: “회전은 하되, 구키를 일정 기간 유지”
리소스 서버의 캐시 문제를 0으로 만들 수는 없습니다. 그래서 IdP 쪽에서도 완충 장치를 둡니다.
1) 키 회전 시 구키를 즉시 삭제하지 말 것
Keycloak에서 새 키를 만들고 활성화하더라도, 기존 키를 일정 기간(토큰 최대 수명 + 여유) 유지해야 합니다.
- 액세스 토큰이 최대 5분이면: 최소 5~10분 이상 구키 유지
- 리프레시 토큰/오프라인 토큰까지 고려하면 더 길게
핵심은 “이미 발급된 토큰은 만료될 때까지 검증 가능”해야 한다는 점입니다.
2) 롤링 배포/클러스터에서 회전 타이밍 표준화
- Keycloak 노드들이 동일한 키셋을 보도록(공유 DB, 캐시/클러스터 설정) 구성
- 회전 작업은 트래픽이 적은 시간대에 수행
- 회전 직후 모니터링(401, JWKS fetch 실패율)을 강화
운영 설계: 401 폭발을 막는 실전 가드레일
1) JWKS 캐시 TTL을 “너무 길게” 잡지 말기
TTL이 길수록 회전 시 장애 시간이 길어집니다.
- 권장 감각: 수분~수십분 사이(환경에 따라)
- 대신
kid miss시 즉시 갱신 패턴으로 보완
2) JWKS 엔드포인트 가용성 확보
JWKS는 인증의 단일 장애 지점이 되기 쉽습니다.
- Keycloak 앞단 LB/Ingress의 keepalive/timeout 튜닝
- CoreDNS/네트워크 간헐 장애 제거
- 가능하면 Keycloak을 내부에서 안정적으로 접근 가능한 주소로 통일
3) 지표/로그에 반드시 남길 것
다음이 있어야 원인 분리가 빨라집니다.
kid not found카운트- JWKS fetch 성공/실패/지연시간
- 401을 “서명 검증 실패 / issuer 불일치 / aud 불일치 / 만료”로 분류
MSA에서 인증 실패가 연쇄 타임아웃으로 번지는 경우(예: 게이트웨이 재시도 폭증)는 데드라인/타임아웃 전파도 함께 점검해야 합니다. 관련해서는 gRPC MSA 데드라인 전파 누락으로 타임아웃 폭증 해결도 참고할 만합니다.
자주 하는 실수 모음
kid 불일치인데 토큰을 “다시 로그인”으로만 해결하려 함
사용자 재로그인은 임시방편일 뿐, 서버가 새 키를 받아오지 못하면 다시 터집니다.
JWKS를 매 요청마다 가져오기
성능도 나쁘고, Keycloak/JWKS가 순간 느려지면 API 전체가 인증에서 병목이 됩니다. 캐시 + kid miss 시 갱신이 정석입니다.
issuer/jwks_uri를 환경별로 하드코딩
프록시/도메인 변경 시 issuer 불일치가 가장 먼저 터집니다. 가능하면 discovery 기반(issuer-uri)을 표준으로 두고, 변경은 한 곳에서만 관리하세요.
결론
Keycloak 환경에서 kid 불일치로 401이 나는 문제는 “JWT가 잘못됐다”기보다, 대개 키 회전과 JWKS 캐시 갱신의 타이밍 문제입니다. 해결의 핵심은 다음 3가지로 요약됩니다.
- kid miss 시 JWKS를 즉시 재조회(1회 재시도)하도록 리소스 서버를 설계한다.
- Keycloak에서 구키를 충분히 오래 유지해 회전 구간을 완충한다.
- JWKS fetch 실패를 줄이기 위해 네트워크/DNS/타임아웃/인그레스를 안정화하고, 지표로 조기 탐지한다.
이 3가지만 지켜도 “갑자기 401 폭발”하는 인증 장애의 대부분은 예방하거나, 최소한 짧은 시간 내에 자동으로 회복되도록 만들 수 있습니다.