Published on

Keycloak OIDC JWT 검증 실패 - kid 불일치 해결

Authors

서버 간 인증을 OIDC로 붙이고 나면, 운영 중 가장 당황스러운 장애 중 하나가 JWT 검증 실패입니다. 특히 로그에 kid 불일치가 찍히면 “토큰은 멀쩡해 보이는데 왜 서명이 검증되지 않지?”라는 상황이 자주 발생합니다. 이 글에서는 Keycloak을 IdP로 쓰는 전형적인 구성에서 kid mismatch가 발생하는 핵심 원인과, 서비스(리소스 서버) 측 검증 로직 및 캐시 전략, 그리고 Keycloak/프록시 설정까지 포함해 실전 해결책을 정리합니다.

아래 내용은 Java(Spring Security) 기준 예시를 포함하지만, 원인 자체는 Node.js, Go, .NET 등 어떤 스택에서도 동일하게 적용됩니다.

증상: kid 불일치가 의미하는 것

JWT 헤더에는 보통 다음과 같은 필드가 있습니다.

  • alg: 서명 알고리즘(예: RS256)
  • typ: 토큰 타입(예: JWT)
  • kid: Key ID. “이 토큰을 서명한 키가 JWKS의 어떤 키인지”를 가리키는 식별자

리소스 서버는 JWT를 검증할 때 다음 순서로 동작합니다.

  1. 토큰 헤더에서 kid를 읽음
  2. IdP의 JWKS(JSON Web Key Set)에서 동일한 kid를 가진 공개키를 찾음
  3. 그 공개키로 서명을 검증

따라서 kid 불일치는 대부분 아래 둘 중 하나입니다.

  • 토큰이 가리키는 kid가 JWKS에 없음(키 회전/캐시/환경 혼선)
  • JWKS는 맞는데, 리소스 서버가 다른 JWKS를 보고 있음(issuer/realm URL, 프록시, 멀티 리얼름/멀티 클러스터)

가장 흔한 원인 7가지와 해결책

1) Keycloak 키 회전(Key Rotation) 후 JWKS 캐시가 갱신되지 않음

Keycloak에서 realm 키를 회전하거나(수동/자동), 인증서/키를 재생성하면 새로운 kid가 발급됩니다. 이때 리소스 서버가 JWKS를 캐싱하고 있으면, 새 토큰의 kid를 찾지 못합니다.

해결 체크리스트

  • 리소스 서버의 JWKS 캐시 TTL을 과도하게 길게 잡지 않기
  • 장애 시 즉시 JWKS 캐시를 강제 갱신할 수 있는 운영 수단 마련
  • Keycloak 키 회전 정책을 운영 절차로 명확히(점검 창, 배포 순서)

Spring Security는 내부적으로 JWK를 캐시합니다. 너무 공격적으로 캐시를 고정하면 회전에 취약해집니다. 아래는 Nimbus 기반으로 JWK 캐시 동작을 제어하는 예시입니다.

import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.jwk.source.RemoteJWKSet;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.context.annotation.Bean;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;

import java.net.URL;

@Bean
JwtDecoder jwtDecoder() throws Exception {
    URL jwkSetUrl = new URL("https://auth.example.com/realms/myrealm/protocol/openid-connect/certs");

    // RemoteJWKSet은 내부 캐시를 사용하며, 네트워크 장애 시 이전 캐시를 활용합니다.
    // 운영 환경에 맞게 타임아웃, 캐시 전략을 별도 구성하는 것을 권장합니다.
    JWKSource<SecurityContext> jwkSource = new RemoteJWKSet<>(jwkSetUrl);

    return NimbusJwtDecoder.withJwkSource(jwkSource).build();
}

장애 대응 관점에서는 “캐시를 완전히 없애기”보다 “짧은 TTL + 강제 갱신 가능”이 더 현실적입니다.

2) issuer(발급자) URL 불일치로 다른 JWKS를 조회함

리소스 서버는 보통 issuer-uri 또는 jwk-set-uri로 검증 설정을 합니다. 여기서 가장 많이 하는 실수가 다음입니다.

  • 내부망 URL과 외부망 URL이 다름
  • 프록시/인그레스 뒤에서 Keycloak의 base URL이 달라짐
  • realm 경로가 다른데도 같은 IdP로 착각함

특히 issuer-uri 기반 자동 설정은 /.well-known/openid-configuration을 따라가며, 그 안의 jwks_uri를 사용합니다. 즉, issuer가 달라지면 JWKS도 달라집니다.

해결책

  • 토큰의 iss 클레임과 리소스 서버 설정의 issuer가 정확히 동일한지 확인
  • 멀티 도메인/프록시 환경이면 Keycloak의 hostname 설정(또는 프런트엔드 URL)을 명확히 고정

Spring Boot 설정 예시입니다.

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.example.com/realms/myrealm
          # 문제가 반복되면 jwk-set-uri를 명시해 디버깅을 단순화할 수 있습니다.
          # jwk-set-uri: https://auth.example.com/realms/myrealm/protocol/openid-connect/certs

isshttps://auth-internal.svc/realms/myrealm인데, 리소스 서버는 https://auth.example.com/realms/myrealm을 issuer로 잡고 있으면, 같은 Keycloak이라도 검증이 어긋날 수 있습니다.

3) Keycloak 클러스터에서 노드별 키가 달라짐(공유 스토리지/DB/캐시 문제)

정상적인 Keycloak 클러스터에서는 realm 키가 DB에 저장되어 모든 노드에 동일하게 적용됩니다. 그런데 다음과 같은 경우 노드마다 키가 달라질 수 있습니다.

  • 노드가 서로 다른 DB를 바라봄(환경 변수/시크릿 오류)
  • 마이그레이션 중 임시로 realm을 복제했고 키가 분기됨
  • 운영/스테이징이 로드밸런서에서 섞임

이 경우 어떤 노드가 토큰을 발급했는지에 따라 kid가 달라지고, JWKS는 다른 노드 기준으로 응답해 불일치가 발생합니다.

해결책

  • Keycloak 모든 노드가 동일 DB를 바라보는지 확인
  • 로드밸런서가 운영/스테이징을 섞지 않는지 확인
  • Keycloak 서비스 디스커버리 및 인그레스 라우팅 규칙 점검

Kubernetes 환경이라면 인그레스/서비스가 교차 라우팅되는지 반드시 확인하세요. 장애가 간헐적이라면 이 케이스 확률이 큽니다.

4) 프록시/인그레스가 JWKS 응답을 캐싱하거나 변형함

CDN, API Gateway, Ingress Controller가 certs 엔드포인트 응답을 캐싱하면서, 키 회전 이후에도 오래된 JWKS를 계속 제공하는 경우가 있습니다. 또는 gzip/헤더 변형으로 인해 클라이언트 라이브러리가 파싱 실패 후 이전 캐시를 계속 쓰는 상황도 생깁니다.

해결책

  • .../protocol/openid-connect/certs 응답에 대한 캐시 정책 확인
  • 프록시가 Cache-Control을 강제로 덮어쓰는지 확인
  • 장애 시 프록시 캐시 퍼지 절차 마련

ALB/Ingress 이슈가 얽히면 원인 파악이 더 어려워집니다. 인그레스 계층에서 401/403이 섞여 보일 때는 이 글도 함께 참고하면 좋습니다.

5) 잘못된 토큰을 검증함(다른 realm, 다른 client, 다른 환경)

현장에서 의외로 많이 나오는 케이스입니다.

  • 프런트가 스테이징 Keycloak에서 발급받은 토큰을 운영 API로 호출
  • 모바일 앱이 캐시된 토큰을 계속 사용
  • 멀티 리얼름 구성에서 realm이 섞임

빠른 판별법

  • 토큰의 iss, aud, azp를 확인
  • 리소스 서버가 기대하는 realm, audience와 일치하는지 확인

아래는 토큰 헤더/페이로드를 로컬에서 빠르게 확인하는 예시입니다(서명 검증 없이 디코딩만).

TOKEN="eyJhbGciOiJSUzI1NiIsImtpZCI6Ii4uLiJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20vcmVhbG1zL215cmVhbG0iLCJhdWQiOiJhY2NvdW50In0.signature"

python - <<'PY'
import base64, json, os

def b64url_decode(s):
    s += '=' * (-len(s) % 4)
    return base64.urlsafe_b64decode(s.encode())

token = os.environ['TOKEN']
header_b64, payload_b64, _ = token.split('.')
print('header:', json.loads(b64url_decode(header_b64)))
print('payload:', json.loads(b64url_decode(payload_b64)))
PY

6) 알고리즘/키 타입 혼선(RS256 vs ES256, x5c 등)

Keycloak은 보통 RS256을 많이 쓰지만, 환경에 따라 EC 키(ES256)를 쓰는 구성도 가능합니다. 리소스 서버 라이브러리가 해당 알고리즘을 지원하지 않거나, 기대하는 키 타입과 실제 JWKS의 kty가 다르면 kid가 같아 보여도 검증이 실패합니다.

해결책

  • 토큰 헤더의 alg 확인
  • JWKS에서 해당 kidkty, use, alg 확인
  • 리소스 서버 검증 라이브러리의 알고리즘 지원 여부 확인

7) 리소스 서버의 검증 필터/체인이 꼬여 다른 디코더를 탐

Spring Security에서는 필터 체인 구성에 따라 다른 JwtDecoder가 사용되거나, 커스텀 필터가 먼저 토큰을 읽고 실패 처리해버리는 경우가 있습니다. 겉으로는 kid mismatch처럼 보여도 실제로는 “다른 설정의 디코더가 토큰을 검증”하고 있을 수 있습니다.

필터 순서/설정이 헷갈린다면 아래 글을 함께 보면 문제를 빨리 줄일 수 있습니다.

실전 디버깅 루틴: 10분 안에 원인 좁히기

운영 장애에서 중요한 건 “정확한 재현”보다 “빠른 분기”입니다. 아래 순서로 보면 kid 불일치 원인을 대부분 빠르게 좁힐 수 있습니다.

1) 토큰에서 kid, iss를 뽑는다

  • kid: 어떤 키를 찾는지
  • iss: 어떤 issuer의 JWKS를 봐야 하는지

2) issuer의 JWKS에서 kid가 존재하는지 확인한다

아래처럼 JWKS를 받아서 kid 목록을 확인합니다.

ISSUER="https://auth.example.com/realms/myrealm"
JWKS_URL="$ISSUER/protocol/openid-connect/certs"

curl -s "$JWKS_URL" | python - <<'PY'
import json, sys
jwks = json.load(sys.stdin)
print([k.get('kid') for k in jwks.get('keys', [])])
PY
  • 목록에 없다: 키 회전/캐시/환경 혼선 가능성 큼
  • 목록에 있다: 다음 단계로(issuer 불일치, 알고리즘, 필터 체인)

3) 간헐적이면 “클러스터/라우팅 섞임”을 의심한다

  • 같은 사용자가 같은 토큰으로 호출했는데 어떤 요청은 성공/실패가 섞인다
  • 특정 파드/특정 AZ에서만 실패한다

이 경우 Keycloak 노드 간 키 불일치 또는 라우팅/환경 섞임 확률이 높습니다.

재발 방지: 운영 관점 체크리스트

키 회전 정책을 릴리즈 프로세스에 포함

  • 키 회전 시점에 리소스 서버 캐시 갱신이 따라오도록 절차화
  • 회전 직후 일정 시간 동안 구/신 키가 JWKS에 함께 존재하는지 확인

issuer를 고정하고, 프록시 환경에서 hostname을 일관되게

  • 외부 도메인 하나로 통일
  • 내부 호출도 가능하면 동일 도메인을 사용(또는 리소스 서버에서 jwk-set-uri를 명시)

장애 대응용 관측 포인트 추가

  • JWT 검증 실패 시 로그에 iss, kid, alg, jwk-set-uri를 남기기(민감정보 제외)
  • JWKS fetch 실패/타임아웃 로그 분리

마무리

kid 불일치는 단순히 “토큰이 이상하다”가 아니라, 대부분 “리소스 서버가 보고 있는 JWKS가 토큰이 가리키는 키셋과 다르다”는 신호입니다. 키 회전과 캐시, issuer 불일치, 클러스터 라우팅 섞임이 상위 80% 원인을 차지합니다.

운영 환경에서는 특히 간헐성 여부가 큰 단서입니다. 간헐적이면 캐시 또는 클러스터/라우팅 문제, 항상 재현되면 issuer/realm 불일치나 알고리즘/필터 체인을 먼저 의심하는 방식으로 접근하면 해결 속도가 크게 빨라집니다.

추가로 로그인 단계에서 302 루프나 프록시 설정 문제가 함께 보인다면, Keycloak 프록시/리다이렉트 구성도 같이 점검하는 것이 좋습니다.