Published on

Node.js JWT 검증 실패 - kid·JWKS 캐시로 401 잡기

Authors

서버에서 JWT 검증이 갑자기 401로 실패하는데, 토큰을 디코딩해보면 exp도 유효하고 서명 알고리즘도 맞아 보이는 상황이 종종 있습니다. 특히 OIDC(OAuth) 기반 로그인(예: Cognito, Auth0, Okta, Keycloak, 자체 IdP)에서 키 로테이션이 발생하거나, JWKS(JSON Web Key Set) 캐시가 꼬이면 kid(Key ID) 관련 오류가 터지면서 장애가 됩니다.

이 글은 Node.js(Express/Fastify/Nest 등)에서 흔히 만나는 패턴인 kid 불일치 → JWKS 조회/캐시 문제 → 401 연쇄를 빠르게 재현/진단하고, 운영 환경에서 안전하게 해결하는 방법(캐시 전략/재시도/관측)을 실전 관점으로 정리합니다.

관련해서 ALB Ingress/OIDC 레이어에서 401이 반복되는 경우는 아래 글의 체크리스트도 같이 보면 원인 분리가 빨라집니다.

증상 패턴: “가끔만” 401이 뜨는 JWT

대표적인 로그/에러는 다음 중 하나로 나타납니다.

  • JsonWebTokenError: invalid signature
  • SigningKeyNotFoundError: Unable to find a signing key that matches 'kid'
  • JWKS rate limit exceeded 또는 ETIMEDOUT/ECONNRESET 같은 네트워크 오류
  • 어떤 인스턴스에서는 OK, 어떤 인스턴스에서는 401(멀티 레플리카/멀티 AZ에서 특히)

이런 경우는 대체로 아래 원인 중 하나입니다.

  1. IdP 키 로테이션으로 새로운 kid가 발급됐는데, 서버가 예전 JWKS를 캐시하고 있음
  2. 캐시 TTL이 너무 길거나(혹은 무한 캐시) 캐시 무효화 로직이 없음
  3. JWKS 엔드포인트 장애/지연으로 갱신에 실패 → 오래된 키로 검증 시도
  4. issuer/audience mismatch(특히 다중 테넌트, 환경별 issuer 혼동)
  5. alg 혼동(RS256 기대인데 HS256로 검증하거나 반대)
  6. 프록시/Ingress에서 Authorization 헤더가 누락/변조

이 글은 13번(= kid·JWKS 캐시)을 중심으로 다루되, 46번도 같이 점검할 수 있게 구성합니다.

JWT 검증에서 kid·JWKS가 왜 문제를 일으키나

OIDC 기반 JWT(보통 RS256)는 토큰 헤더에 다음을 포함합니다.

{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "abc123..."
}

서버는 kid를 보고 JWKS에서 같은 kid를 가진 공개키를 찾아 서명을 검증합니다.

  • 토큰은 이미 새 kid로 발급됨
  • 서버는 캐시에 남아 있는 옛 JWKS만 가지고 있음

이 조합이 되면 바로 SigningKeyNotFoundError 또는 invalid signature로 401이 납니다.

핵심은 kid가 바뀌는 건 정상이라는 점입니다. 문제는 서버가 그 변화를 얼마나 빨리/안전하게 따라가느냐입니다.

10분 진단 체크리스트 (운영에서 바로 쓰는 순서)

1) 실제로 어떤 kid가 들어오는지 확인

토큰을 검증하기 전에, 먼저 “들어오는 토큰의 kid가 무엇인지”를 로그로 남기면 원인 분리가 빨라집니다.

import jwt from 'jsonwebtoken';

function peekKid(token) {
  const decoded = jwt.decode(token, { complete: true });
  return decoded?.header?.kid;
}
  • 특정 시간대부터 새로운 kid가 등장했는지
  • 401이 나는 요청의 kid가 특정 값으로 고정되는지

2) 현재 JWKS에 그 kid가 존재하는지 확인

IdP의 JWKS URL은 보통 다음 형태입니다.

  • https://{issuer}/.well-known/jwks.json
  • 또는 /.well-known/openid-configuration에서 jwks_uri 확인
curl -s https://issuer.example.com/.well-known/jwks.json | jq '.keys[].kid'
  • JWKS에 kid가 없다면: IdP 발급/배포 지연 또는 issuer를 잘못 보고 있는 것
  • JWKS에는 있는데 서버가 못 찾는다면: 서버 캐시/네트워크/라이브러리 설정 문제

3) issuer/audience가 환경별로 섞였는지 확인

가장 흔한 실수는 issuer가 prod인데 jwksUri는 staging을 보거나(혹은 반대), audience가 앱 클라이언트 ID와 다르게 설정된 경우입니다.

  • 토큰의 iss, aud를 디코딩해서 기대값과 비교
  • 멀티 테넌트면 iss별로 JWKS를 분리 캐시해야 함

4) 네트워크 문제: JWKS 조회가 실패하는지 확인

JWKS는 외부 HTTPS 호출입니다. 장애 시나리오:

  • DNS/egress 문제
  • IPv6 경로에서만 실패
  • 프록시가 TLS를 차단

EKS에서 egress/STS 류가 IPv6 경로에서만 실패하는 패턴은 아래 글과 유사하게 나타날 수 있습니다(원인 자체는 다르지만 “특정 네트워크 경로에서만 외부 호출 실패”라는 점이 같습니다).

해결 전략: “kid miss 시 JWKS 강제 갱신 + 안전한 캐시 TTL”

운영에서 가장 안정적인 방식은 다음 3가지를 함께 적용하는 것입니다.

  1. 기본 JWKS 캐시(짧은 TTL)
  2. kid 미스(키 못 찾음) 발생 시 캐시 무시하고 즉시 JWKS 재조회
  3. 재조회도 실패하면 401을 내되, 관측 지표/로그로 즉시 알림

여기서 중요한 포인트는 “무조건 매 요청마다 JWKS를 가져오지 말 것”입니다. 그건 IdP rate limit과 레이턴시를 자폭으로 끌어옵니다.

Node.js 구현 1: jose로 JWKS 캐시 + kid 로테이션 대응

jose는 Node.js에서 JWT/JWKS를 다루기 좋은 표준 라이브러리입니다.

설치

npm i jose

구현 예시 (Express 미들웨어 형태)

import { jwtVerify, createRemoteJWKSet } from 'jose';

const issuer = process.env.OIDC_ISSUER; // 예: https://issuer.example.com
const audience = process.env.OIDC_AUDIENCE; // 예: your-client-id

// jose의 RemoteJWKSet은 내부 캐시를 가짐
// cacheMaxAge: JWKS 캐시 TTL(ms)
// cooldownDuration: 연속 실패 시 재조회 쿨다운(ms)
const JWKS = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`), {
  cacheMaxAge: 5 * 60 * 1000,      // 5분
  cooldownDuration: 30 * 1000      // 30초
});

export async function authMiddleware(req, res, next) {
  try {
    const auth = req.headers.authorization;
    if (!auth?.startsWith('Bearer ')) {
      return res.status(401).json({ message: 'Missing bearer token' });
    }

    const token = auth.slice('Bearer '.length);

    const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
      issuer,
      audience,
      algorithms: ['RS256']
    });

    // 관측: kid/iss/aud를 남겨두면 장애 시 추적이 쉬움
    req.user = {
      sub: payload.sub,
      scope: payload.scope,
      iss: payload.iss,
      aud: payload.aud,
      kid: protectedHeader.kid
    };

    return next();
  } catch (err) {
    // jose는 kid 미스/네트워크 오류 등 다양한 에러를 던짐
    // 운영에서는 err.name, err.code, message를 구조화해서 남기자
    req.log?.warn?.({ err }, 'JWT verification failed');
    return res.status(401).json({ message: 'Invalid token' });
  }
}

kid 미스 때 “즉시 강제 갱신”이 필요할까?

createRemoteJWKSet은 기본적으로 캐시를 사용하지만, kid가 캐시에 없으면 원격 JWKS를 다시 가져오려는 동작을 합니다(버전별 세부 동작은 다를 수 있으니 릴리즈 노트를 확인). 그럼에도 다음 상황에선 추가 보강이 필요합니다.

  • JWKS endpoint가 순간적으로 5xx/timeout → 갱신 실패 후 오래된 캐시만 사용
  • 서버가 여러 대이고, 일부만 캐시 갱신이 늦음

이때는 아래처럼 “kid 미스/키 없음” 계열 에러를 감지해 1회에 한해 재시도를 넣는 방식이 실전에서 효과적입니다.

import { jwtVerify, createRemoteJWKSet } from 'jose';

const JWKS = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`), {
  cacheMaxAge: 5 * 60 * 1000,
  cooldownDuration: 10 * 1000
});

async function verifyWithRetry(token) {
  try {
    return await jwtVerify(token, JWKS, { issuer, audience, algorithms: ['RS256'] });
  } catch (err) {
    // 네트워크 일시 장애/키 로테이션 타이밍에만 제한적으로 재시도
    const retryable = [
      'JWKSNoMatchingKey',
      'JWKSMultipleMatchingKeys',
      'JWKSInvalid',
      'JWTInvalid'
    ].includes(err?.code) || /no matching key|kid/i.test(err?.message || '');

    if (!retryable) throw err;

    // 아주 짧은 지연 후 1회 재시도(과도한 재시도는 역효과)
    await new Promise(r => setTimeout(r, 50));
    return await jwtVerify(token, JWKS, { issuer, audience, algorithms: ['RS256'] });
  }
}

재시도는 만능이 아닙니다. 하지만 키 로테이션 직후 수 초~수십 초 동안만 401이 나는 케이스에서 체감 효과가 큽니다.

Node.js 구현 2: jsonwebtoken + jwks-rsa 조합 (레거시에서 흔함)

이미 passport-jwt/express-jwt/jsonwebtoken 스택을 쓰고 있다면 jwks-rsa가 많이 사용됩니다.

설치

npm i jsonwebtoken jwks-rsa

구현 예시

import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';

const issuer = process.env.OIDC_ISSUER;
const audience = process.env.OIDC_AUDIENCE;

const client = jwksClient({
  jwksUri: `${issuer}/.well-known/jwks.json`,
  cache: true,
  cacheMaxEntries: 10,
  cacheMaxAge: 5 * 60 * 1000, // 5분
  rateLimit: true,
  jwksRequestsPerMinute: 10,
  timeout: 3000
});

function getKey(header, callback) {
  client.getSigningKey(header.kid, (err, key) => {
    if (err) return callback(err);
    const signingKey = key.getPublicKey();
    callback(null, signingKey);
  });
}

export function authMiddleware(req, res, next) {
  const auth = req.headers.authorization;
  if (!auth?.startsWith('Bearer ')) return res.sendStatus(401);
  const token = auth.slice('Bearer '.length);

  jwt.verify(
    token,
    getKey,
    {
      issuer,
      audience,
      algorithms: ['RS256']
    },
    (err, decoded) => {
      if (err) {
        req.log?.warn?.({ err }, 'JWT verify failed');
        return res.sendStatus(401);
      }
      req.user = decoded;
      next();
    }
  );
}

이 조합에서 자주 터지는 포인트

  • cacheMaxAge를 너무 길게 잡아 로테이션을 못 따라감
  • jwksRequestsPerMinute가 너무 낮아, 로테이션/캐시 미스 순간에 갱신이 막힘
  • timeout이 너무 짧아(또는 너무 길어) 장애가 확대

운영에서는 cache TTL(예: 515분) + rate limit(분당 1060) + timeout(2~5초) 정도를 기본값으로 두고, 트래픽/IdP 정책에 맞춰 조정하는 편이 안전합니다.

캐시 설계: “짧은 TTL + 멀티 인스턴스 일관성”

단일 프로세스 메모리 캐시만 쓰면, 레플리카마다 캐시 갱신 타이밍이 다릅니다. 그래서 “A 파드는 되고 B 파드는 401” 같은 현상이 나옵니다.

권장 패턴

  • 가능하면 중앙 캐시(예: Redis)로 JWKS를 공유
  • 또는 애플리케이션 시작 시점에 프리페치(prefetch) + 주기적 갱신
  • kid 미스 시에는 즉시 원격 재조회(단, rate limit 주의)

Redis를 붙이는 게 과하다면, 최소한 아래는 지키는 게 좋습니다.

  • TTL을 길게 잡지 말기(보통 5~15분)
  • kid 미스/서명키 없음 에러를 별도 카운트해서 알림
  • JWKS fetch 실패율/지연을 메트릭으로 수집

관측(Observability): 401을 “원인별로” 쪼개기

JWT 검증 실패를 전부 401로만 뭉개면, kid 로테이션인지 헤더 누락인지 구분이 안 됩니다.

로그/메트릭에 최소한 아래를 남기면 좋습니다.

  • protectedHeader.kid
  • payload.iss, payload.aud
  • 에러 타입(예: SigningKeyNotFoundError, ERR_JWKS_TIMEOUT 등)
  • JWKS fetch latency, 실패율

예: pino를 쓴다면

req.log.warn({
  msg: 'jwt_failed',
  kid,
  iss,
  aud,
  errName: err.name,
  errCode: err.code,
  errMsg: err.message
});

그리고 인프라 레이어(예: Ingress/ALB)에서 401이 반복될 때는 애플리케이션이 실제로 401을 냈는지, 아니면 앞단에서 차단했는지부터 분리해야 합니다. 이 부분은 아래 글의 헤더/인증 흐름 점검이 도움이 됩니다.

운영에서 자주 하는 실수와 방지책

실수 1) JWKS를 “영구 캐시”함

키 로테이션은 언젠가 반드시 옵니다. 영구 캐시는 결국 장애로 이어집니다.

  • 방지: TTL을 명시하고, kid 미스 시 강제 갱신 경로를 둠

실수 2) issuer가 환경별로 섞임

staging 토큰을 prod에서 검증하거나, prod issuer로 설정했는데 실제로는 다른 테넌트의 JWKS를 봄.

  • 방지: iss/aud를 반드시 검증 옵션으로 강제
  • 방지: 멀티 테넌트면 iss별 JWKS 캐시 분리

실수 3) 장애 시 JWKS 호출 폭주(Thundering herd)

캐시 미스가 동시에 발생하면 모든 워커가 JWKS를 때립니다.

  • 방지: 라이브러리의 cooldownDuration/rate limit 활용
  • 방지: 단일 플라이트(singleflight) 패턴(갱신 중이면 다른 요청은 대기/기존 캐시 사용)

실수 4) Authorization 헤더가 중간에서 사라짐

프록시/Ingress 설정에 따라 Authorization이 백엔드로 전달되지 않거나, Bearer 앞뒤 공백/대소문자 이슈가 생깁니다.

  • 방지: Ingress/프록시에서 헤더 전달 규칙 확인
  • 방지: 애플리케이션에서 "Missing bearer token"을 별도 카운트

결론: 401의 진짜 원인은 “kid 로테이션 + 캐시 설계”인 경우가 많다

Node.js JWT 검증 401을 안정적으로 줄이려면, 단순히 라이브러리를 붙이는 것을 넘어 JWKS 캐시 TTL, kid 미스 시 갱신, 네트워크 실패 시 동작, 관측 지표까지 포함한 운영 설계가 필요합니다.

실전 권장 조합은 다음과 같습니다.

  • jose + createRemoteJWKSet (표준/유지보수 용이)
  • JWKS 캐시 TTL 5~15분
  • kid 미스/갱신 실패를 메트릭으로 분리
  • 필요 시 1회 재시도(짧은 지연)

이렇게 구성하면 “토큰은 정상인데 가끔 401” 같은 난감한 장애를 대부분 제거할 수 있고, 키 로테이션이 와도 서비스가 흔들리지 않습니다.