Published on

JWT kid 누락·불일치로 JWKS 검증 실패 해결

Authors

서버에서 JWT를 검증할 때 가장 흔한 장애 중 하나가 JWKS 기반 키 조회 단계에서 막히는 문제입니다. 특히 토큰 헤더의 kid없거나, 있지만 JWKS에 없는 값이면 검증 라이브러리는 공개키를 찾지 못해 서명 검증을 실패시키고, 결과적으로 인증이 전부 401로 무너집니다.

이 글은 kid 누락·불일치로 인한 JWKS 검증 실패를 재현 가능한 형태로 분해하고, IdP(발급자)와 리소스 서버(검증자)에서 각각 무엇을 확인/수정해야 하는지, 그리고 운영에서 재발하지 않게 만드는 안전장치를 정리합니다.

문제 증상: 로그는 다르지만 본질은 같다

라이브러리/언어마다 에러 메시지는 다르지만, 핵심은 “토큰 헤더의 kid로 JWKS에서 키를 찾을 수 없다”입니다.

대표 증상 패턴:

  • No matching JWK found for kid (키 매칭 실패)
  • Unable to find a signing key that matches (서명키 선택 실패)
  • JWKS endpoint did not contain any keys (JWKS 응답 비정상)
  • invalid signature 로 뭉뚱그려 나오지만 내부 원인은 키 선택 실패인 경우

운영에서는 토큰 자체가 정상처럼 보이기 때문에, 네트워크/캐시/시간 동기화 등으로 샛길로 빠지기 쉽습니다. 진단을 빠르게 하려면 “kid와 JWKS의 kid 목록이 맞는가”를 최우선으로 확인해야 합니다.

JWT와 JWKS에서 kid가 하는 일

JWT는 보통 다음 구조를 가집니다.

  • Header: 어떤 알고리즘(alg)으로 어떤 키(kid)로 서명했는지
  • Payload: 클레임(iss, aud, exp 등)
  • Signature: 서명

JWKS(JSON Web Key Set)는 공개키 목록이며, 각 키는 kid로 식별됩니다. 검증자는 다음 흐름으로 동작합니다.

  1. JWT 헤더에서 kid를 읽는다
  2. JWKS에서 동일한 kid를 가진 JWK를 찾는다
  3. 해당 공개키로 서명을 검증한다

kid가 없으면 “어떤 키를 써야 하는지” 결정할 수 없고, kid가 있는데 JWKS에 없으면 “키 회전 이후 구 키로 서명된 토큰”이거나 “잘못된 JWKS를 보고 있음” 같은 상황이 됩니다.

가장 흔한 원인 8가지 (우선순위 순)

1) 토큰 헤더에 kid 자체가 없다

IdP 설정 또는 커스텀 서명 로직에서 kid를 넣지 않는 경우입니다. 키가 1개뿐이면 어떤 라이브러리는 “유일한 키로 시도”하지만, 대부분은 안전을 위해 실패합니다.

확인 방법(로컬에서 헤더 디코딩):

node -e "const t=process.argv[1]; const h=JSON.parse(Buffer.from(t.split('.')[0],'base64url').toString()); console.log(h)" "YOUR_JWT"

출력된 헤더에 kid가 없으면 IdP 쪽에서 토큰 발급 정책을 수정해야 합니다.

2) kid가 있는데 JWKS에 같은 kid가 없다

가장 흔한 케이스입니다.

  • IdP에서 키를 회전했고, 예전 키로 발급된 토큰이 아직 유효함
  • 리소스 서버가 캐시된 JWKS를 계속 사용함
  • 리소스 서버가 잘못된 테넌트/리전/환경의 JWKS를 조회함

JWKS에서 kid 목록 확인:

curl -s "https://issuer.example.com/.well-known/jwks.json" | jq -r '.keys[].kid'

JWT 헤더의 kid가 위 목록에 없으면 이 항목에 해당합니다.

3) iss가 다른데 같은 JWKS를 보고 있다

멀티 테넌트/멀티 환경에서 자주 발생합니다.

  • isshttps://idp-prod.example.com인데 JWKS는 스테이징을 조회
  • iss 검증을 끄고 “그냥 JWKS로만” 검증하다가 타 환경 토큰이 섞임

JWT payload에서 iss 확인:

node -e "const t=process.argv[1]; const p=JSON.parse(Buffer.from(t.split('.')[1],'base64url').toString()); console.log(p.iss, p.aud)" "YOUR_JWT"

iss는 반드시 “검증자가 신뢰하는 발급자”와 정확히 일치해야 하며, JWKS URL도 그 발급자의 well-known 경로에서 파생되는 것이 안전합니다.

4) JWKS 캐시 TTL이 과도하거나 무효화가 안 된다

키 회전이 발생하면 JWKS도 바뀌는데, 검증 서버가 JWKS를 너무 오래 캐시하면 새 kid를 찾지 못합니다.

  • 인메모리 캐시 TTL이 1시간 이상
  • CDN이 .well-known/jwks.json을 캐시
  • 서버리스 런타임이 컨테이너 재사용으로 캐시가 오래 유지

해결 방향은 “캐시를 하되, 키 미스 발생 시 즉시 재조회” 패턴입니다. 아래 예제에서 다룹니다.

5) alg 혼선: RS256 토큰인데 ES256 키를 찾는다

kid가 맞아도, 라이브러리가 alg를 기반으로 키 타입을 제한하는 경우가 있습니다.

  • JWT 헤더의 algRS256인지 ES256인지 확인
  • JWKS의 kty(RSA/EC)와도 일치해야 함

보안 측면에서 alg는 반드시 allowlist로 제한하세요. alg를 토큰에 맡기면 알고리즘 다운그레이드 공격 위험이 있습니다.

6) kid가 URL-safe가 아닌 형태로 인코딩/정규화가 달라짐

IdP가 kid를 base64url로 넣는데, 중간 컴포넌트가 이를 디코딩/재인코딩하면서 값이 바뀌는 경우가 있습니다. 드물지만 API Gateway, 커스텀 프록시, 로깅 마스킹 로직에서 발생할 수 있습니다.

7) JWKS 엔드포인트가 부분 장애(빈 keys) 또는 권한 문제

  • 간헐적으로 keys가 빈 배열로 내려옴
  • 네트워크 타임아웃/프록시 차단
  • 사설망에서만 접근 가능한 JWKS

이때도 결과는 “kid 매칭 실패”로 보일 수 있습니다. 원인 분리를 위해 JWKS 응답 원문을 꼭 캡처하세요.

8) 토큰이 JWE(암호화)인데 JWS(서명)로 검증하려 함

JWE는 header.payload.signature 형태가 아니라 5파트 구조이며, 검증/복호화 절차가 다릅니다. 이 경우도 kid 관련 혼선이 발생합니다.

빠른 진단 체크리스트 (현장에서 바로 쓰는 순서)

  1. JWT를 디코딩해서 header의 kid, alg 확인
  2. payload의 iss, aud, exp 확인
  3. iss에서 파생된 JWKS URL이 맞는지 확인
  4. JWKS의 keys[].kid 목록에 해당 kid가 존재하는지 확인
  5. 없으면
    • 키 회전 직후인지
    • 검증 서버 캐시 TTL이 과도한지
    • 다른 환경의 JWKS를 보고 있는지
  6. 있으면
    • alg allowlist와 키 타입(kty) 불일치 여부
    • 라이브러리 설정(issuer/audience) 불일치 여부

키 회전/캐시 문제가 MSA 환경에서 연쇄 장애로 번지기 쉬운데, 장애 전파 관점은 MSA Saga 패턴 - 보상 트랜잭션 실패 디버깅에서 다룬 “부분 실패가 전체 플로우를 막는 방식”과 유사합니다. 인증 실패는 보상 트랜잭션도 못 타는 형태로 확장되기 때문에, 키 회전 시나리오를 배포 체크리스트에 포함하는 게 좋습니다.

Node.js에서 안전한 JWKS 검증 구현 (kid 미스 시 재조회)

아래 예시는 jose를 사용해 JWKS를 조회하고, kid 미스나 키 회전 상황에서 재시도할 수 있게 구성한 패턴입니다.

핵심 포인트:

  • issueraudience를 반드시 검증
  • 허용 알고리즘을 제한
  • JWKS는 캐시하되, kid 미스가 나면 한 번 강제 갱신
import { jwtVerify, createRemoteJWKSet } from 'jose'

const ISSUER = 'https://issuer.example.com'
const AUDIENCE = 'api://my-service'
const JWKS_URL = new URL('/.well-known/jwks.json', ISSUER)

// 기본적으로 jose의 RemoteJWKSet은 캐시/리프레시를 수행합니다.
// 다만 운영 요구에 맞게 timeout, cooldown 등을 조정할 수 있습니다.
const jwks = createRemoteJWKSet(JWKS_URL, {
  timeoutDuration: 3000,
  cooldownDuration: 30_000,
})

export async function verifyAccessToken(token) {
  // 1차 검증
  try {
    const { payload, protectedHeader } = await jwtVerify(token, jwks, {
      issuer: ISSUER,
      audience: AUDIENCE,
      algorithms: ['RS256'],
    })
    return { payload, protectedHeader }
  } catch (e) {
    // kid 미스/키 회전 가능성: 한 번 더 시도(강제 갱신에 준하는 효과)
    // jose는 내부적으로 필요 시 JWKS를 다시 가져오므로,
    // 여기서는 재시도를 통해 일시적 캐시/경합 문제를 완화합니다.
    const msg = String(e?.message || e)
    const looksLikeKidMiss = msg.includes('no applicable key') || msg.includes('JWK') || msg.includes('kid')

    if (!looksLikeKidMiss) throw e

    const { payload, protectedHeader } = await jwtVerify(token, jwks, {
      issuer: ISSUER,
      audience: AUDIENCE,
      algorithms: ['RS256'],
    })
    return { payload, protectedHeader }
  }
}

주의:

  • “무한 재시도”는 금지입니다. 키 미스는 영구 오류일 수도 있습니다.
  • algorithms를 토큰 헤더에 맡기지 말고 서버에서 고정하세요.

Node 런타임에서 ESM/CJS 혼용 때문에 인증 모듈 로딩이 깨지는 경우도 있는데, 그런 상황이면 Node.js ESM·CJS 혼용 시 ERR_REQUIRE_ESM 해결도 함께 확인하면 좋습니다.

Express 미들웨어 예시: 관측 가능성까지 포함

인증 실패는 보안 이벤트이기도 하지만, 운영 관점에서는 “왜 실패했는지”를 빠르게 분류해야 합니다. 아래는 kidiss를 로그에 남기되, 토큰 원문은 남기지 않는 예시입니다.

import express from 'express'
import { decodeProtectedHeader, decodeJwt } from 'jose'
import { verifyAccessToken } from './auth.js'

const app = express()

app.use(async (req, res, next) => {
  const auth = req.headers.authorization || ''
  const token = auth.startsWith('Bearer ') ? auth.slice(7) : null

  if (!token) return res.status(401).json({ error: 'missing_token' })

  try {
    const { payload } = await verifyAccessToken(token)
    req.user = payload
    return next()
  } catch (e) {
    // 토큰 원문 로그 금지. 대신 header/payload 일부만 추출.
    let kid
    let iss
    let alg

    try {
      const h = decodeProtectedHeader(token)
      kid = h.kid
      alg = h.alg
      const p = decodeJwt(token)
      iss = p.iss
    } catch (_) {}

    req.log?.warn?.({ err: String(e), kid, alg, iss }, 'jwt_verify_failed')
    return res.status(401).json({ error: 'invalid_token' })
  }
})

app.get('/private', (req, res) => {
  res.json({ ok: true, sub: req.user?.sub })
})

이렇게 해두면 장애 시점에 kid가 무엇이었는지, iss가 어디였는지 빠르게 특정할 수 있어 “잘못된 JWKS를 보고 있었다” 같은 실수를 줄일 수 있습니다.

IdP(발급자) 측 해결: 키 회전 전략이 핵심

kid 불일치의 근본 원인은 대부분 “키 회전 정책과 토큰 TTL의 불일치”입니다.

키 회전 시 지켜야 할 원칙

  • 새 키를 JWKS에 먼저 추가하고(dual publish)
  • 일정 기간(최대 토큰 TTL + 여유) 동안 구 키도 유지한 뒤
  • 마지막으로 구 키를 제거

즉, 토큰 유효기간이 1시간이면 최소 1시간 이상은 구 키를 JWKS에 남겨야 합니다. 액세스 토큰은 짧게, 리프레시 토큰은 별도 채널로 관리하는 이유가 여기에 있습니다.

kid 생성 규칙

  • 충돌이 나지 않게 충분히 유니크해야 함
  • 사람이 읽기 쉬울 필요는 없지만, 운영에서 추적 가능한 형태면 좋음
  • 키 재생성 시 kid 재사용 금지(동일 kid에 다른 키를 매핑하면 검증 캐시와 충돌)

검증자(리소스 서버) 측 해결: 캐시와 폴백 설계

1) JWKS 캐시 TTL을 합리적으로

  • 기본은 5분 내외로 시작
  • 트래픽이 크면 캐시가 필요하지만, 회전 대응을 위해 너무 길면 안 됨

가능하면 JWKS 응답의 Cache-Control 헤더를 존중하되, 운영 정책에 따라 상한을 두는 것도 방법입니다.

2) kid 미스 시 1회 재조회

  • 캐시가 구 버전일 수 있으므로, kid 미스에서는 즉시 한 번 더 시도
  • 그래도 실패하면 401 처리

3) 멀티 이슈어 지원 시 iss별 JWKS 분리

하나의 JWKS 캐시를 공유하면 iss가 섞일 때 사고가 납니다.

  • iss를 먼저 검증(또는 allowlist 확인)
  • iss마다 JWKS URL과 캐시를 분리

4) 관측 가능성: kid/iss/JWKS fetch 실패율

다음 지표를 대시보드로 두면, 키 회전 직후 문제를 빠르게 감지할 수 있습니다.

  • JWT 검증 실패율(401)
  • JWKS fetch 실패율 및 지연
  • kid 미스 카운트
  • iss 불일치 카운트

인프라 레벨 장애(예: 클러스터 네트워크/프록시 문제)로 JWKS 호출이 막히면 인증이 전부 실패할 수 있습니다. 쿠버네티스 환경에서 이런 “외부 엔드포인트 접근 실패”는 다른 장애와 유사한 결로 나타나니, 필요하면 EKS kube-proxy를 IPVS로 바꾼 뒤 통신 장애 복구처럼 네트워크 관점 진단도 병행하세요.

보안 주의사항: 문제를 고치려다 더 위험해지는 패턴

장애가 급할 때 아래와 같은 임시 조치를 넣으면 보안 사고로 이어질 수 있습니다.

  • alg 검증을 끄고 토큰 헤더를 신뢰
  • iss/aud 검증을 끄고 “서명만 맞으면 통과”
  • kid가 없으면 “첫 번째 키로 검증” 같은 임의 폴백

특히 kid 누락을 “키가 하나니까 괜찮다”로 처리하면, 나중에 키가 추가되는 순간부터 예측 불가능한 검증 결과가 나옵니다.

운영에서 재발 방지: 배포/회전 체크리스트

  • 토큰 발급 시 헤더에 kid 포함 여부를 자동 테스트
  • 키 회전 시나리오 테스트
    • 구 토큰(구 kid)이 유효한 동안 검증이 되는지
    • 신 토큰(신 kid)이 즉시 검증되는지
  • JWKS 응답이 빈 keys를 반환하지 않는지 모니터링
  • 환경별 iss와 JWKS URL 매핑을 코드로 고정(설정 실수 방지)

마무리

kid 누락·불일치로 인한 JWKS 검증 실패는 “JWT가 이상하다”기보다, 키 배포(JWKS)와 검증 캐시, 그리고 키 회전 정책의 경계에서 발생하는 운영 문제인 경우가 대부분입니다.

  • kid가 없으면 발급 정책 수정
  • kid가 있는데 JWKS에 없으면 키 회전/캐시/환경 매핑을 의심
  • 검증자는 iss/aud/alg를 엄격히 검증하면서도, kid 미스에 한해 1회 재조회로 회전 순간을 흡수

이 세 가지만 정리해두면, 인증 장애의 상당수를 “짧은 시간 내 원인 특정 및 복구”로 바꿀 수 있습니다.