Published on

Auth0+React JWT 검증 실패 - JWKS 캐시·키회전 대응

Authors

서론

Auth0 + React 조합에서 로그인/토큰 발급은 잘 되는데, 어느 순간부터 API 호출이 간헐적으로 401로 떨어지는 케이스가 있습니다. 대개 에러 메시지는 invalid signature, kid not found, JWKS endpoint error 같이 “서명 검증” 쪽으로 모이죠.

이 문제는 프론트(React) 자체가 JWT를 검증하려 해서 생기기보다는, 백엔드(리소스 서버)에서 JWT를 검증할 때 JWKS(JSON Web Key Set) 캐시가 키 회전(Key Rotation)을 따라가지 못해 발생하는 경우가 많습니다. 특히 다음 조건이 겹치면 재현이 쉬워집니다.

  • Auth0 테넌트에서 signing key가 교체되었거나(자동/수동 회전)
  • 검증 라이브러리(또는 커스텀 코드)가 JWKS를 과도하게 캐시하거나
  • 멀티 인스턴스/서버리스 환경에서 인스턴스별 캐시가 제각각이거나
  • 네트워크 오류로 JWKS 갱신이 실패했는데, 실패를 “캐시”해버렸거나

이 글에서는 “왜 kid가 갑자기 안 맞는가”, JWKS 캐시를 어떻게 설계해야 안전한가, 그리고 Node/Express 기준의 실전 코드까지 정리합니다. (유사한 증상을 Node.js에서 다룬 글도 참고하면 도움이 됩니다: Node.js JWT 검증 실패 - kid·JWKS 캐시로 401 잡기)


JWT/JWKS/키회전이 만드는 전형적인 실패 시나리오

1) JWT 헤더의 kid와 JWKS의 키가 불일치

Auth0가 발급한 JWT의 헤더에는 보통 이런 형태가 들어있습니다.

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

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

  • 정상: JWKS에 kid=abc123...가 존재 → 공개키로 검증 성공
  • 장애: JWKS 캐시에 옛날 키 목록만 남아있음 → kid not found → 401

2) 키 회전 직후 “일부 토큰만” 실패하는 이유

키 회전이 일어나면 대개 이런 타임라인이 생깁니다.

  1. Auth0가 새 키로 서명하기 시작(새 kid 등장)
  2. 기존 토큰(옛 kid)은 만료될 때까지 계속 유효할 수 있음
  3. 백엔드가 JWKS를 갱신하지 못하면 새 kid 토큰을 검증 못함

즉, 같은 사용자라도 어떤 시점에 발급된 토큰을 들고 오느냐에 따라 성공/실패가 갈립니다.

3) “React에서 검증하면 되지 않나?”가 위험한 이유

React는 브라우저 환경이라 비밀키/검증키 관리가 곤란하고, 무엇보다 리소스 서버가 최종 권한 판단 주체여야 합니다.

  • 프론트는 토큰을 “저장/전달”
  • 백엔드는 토큰을 “검증/인가”

따라서 이 이슈의 핵심은 React가 아니라 API 서버의 JWKS 캐시 정책입니다.


Auth0 환경에서 꼭 확인할 설정 체크리스트

1) 토큰 서명 알고리즘: RS256 권장

Auth0 SPA(React) + API라면 보통 RS256을 씁니다. HS256이면 공유 비밀키가 필요해 운영이 더 민감해집니다.

  • Auth0 Dashboard → Application / API 설정에서 signing algorithm 확인

2) Issuer / Audience 불일치도 401을 만든다

키 문제처럼 보이지만 사실은 iss, aud 검증 실패인 경우도 흔합니다.

  • iss: https://YOUR_DOMAIN/
  • aud: API Identifier

이 값이 환경별로 달라질 때(스테이징/프로덕션) “가끔”이 아니라 “항상” 실패합니다. 반면 키 회전/JWKS 캐시 문제는 간헐적인 경우가 많습니다.


JWKS 캐시 전략: ‘캐시를 한다’가 아니라 ‘어떻게’ 한다

JWKS는 공개키라서 자주 받아도 보안상 큰 문제는 없지만, 트래픽/지연/레이트리밋 때문에 캐시가 필요합니다. 문제는 다음입니다.

  1. TTL이 너무 길다 → 키 회전 반영이 늦다
  2. 실패 응답을 캐시한다 → 장애가 길어진다
  3. kid miss 시에도 캐시를 안 갱신한다 → 새 키를 영원히 못 본다

권장 패턴은 아래 2가지를 동시에 만족하는 것입니다.

  • 평소에는 캐시 히트로 빠르게 검증
  • kid가 캐시에 없으면 즉시 JWKS를 강제 갱신하고 1회 재시도

구현 예제(권장): jose + JWKS 원격 로더 + kid miss 재시도

Node/Express에서 jose를 쓰면 JWKS를 원격에서 가져오는 로더를 만들 수 있습니다.

> 핵심: 캐시 TTL을 합리적으로 두고, 검증 실패(특히 kid miss) 시 JWKS를 리프레시하는 흐름을 넣습니다.

1) 설치

npm i jose express

2) JWT 검증 미들웨어

import express from "express";
import { jwtVerify, createRemoteJWKSet } from "jose";

const app = express();

const issuer = `https://${process.env.AUTH0_DOMAIN}/`;
const audience = process.env.AUTH0_AUDIENCE;

// Auth0 JWKS URL
const jwksUrl = new URL(`https://${process.env.AUTH0_DOMAIN}/.well-known/jwks.json`);

// Remote JWKS 로더 (내부적으로 캐시/쿨다운/동시요청 제어를 수행)
const JWKS = createRemoteJWKSet(jwksUrl, {
  // jose 버전에 따라 옵션이 다를 수 있습니다.
  // 아래 값은 "너무 길지 않게" 운영 환경에 맞춰 조정하세요.
  cooldownDuration: 30_000, // JWKS 재요청 쿨다운(밀리초)
  timeoutDuration: 5_000
});

async function verifyAccessToken(token) {
  return jwtVerify(token, JWKS, {
    issuer,
    audience,
    algorithms: ["RS256"]
  });
}

function authRequired() {
  return async (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);

      // 1차 검증
      const result = await verifyAccessToken(token);
      req.user = result.payload;
      return next();
    } catch (e) {
      // jose는 다양한 에러 타입을 던집니다.
      // 여기서 중요한 건 "kid를 못 찾아서" 실패하는 경우에 대한 대응입니다.

      // 보수적 접근: kid 관련/키 관련 실패로 보이면 1회 재시도
      const msg = String(e?.message || "");
      const maybeKidProblem =
        msg.includes("no applicable key") ||
        msg.includes("JWK") ||
        msg.includes("kid") ||
        msg.includes("signature");

      if (!maybeKidProblem) {
        return res.status(401).json({ message: "Invalid token" });
      }

      try {
        // createRemoteJWKSet은 내부적으로 쿨다운/캐시를 가지므로
        // "무조건 강제 갱신" API가 없는 버전도 있습니다.
        // 대신 "재시도"를 통해 필요 시 재요청이 일어나도록 설계합니다.
        const auth = req.headers.authorization;
        const token = auth.slice("Bearer ".length);
        const result = await verifyAccessToken(token);
        req.user = result.payload;
        return next();
      } catch (e2) {
        return res.status(401).json({ message: "Invalid token (after jwks retry)" });
      }
    }
  };
}

app.get("/api/private", authRequired(), (req, res) => {
  res.json({ ok: true, sub: req.user.sub });
});

app.listen(3000);

포인트

  • 캐시 TTL을 “길게” 잡기보다, kid miss 시 재시도로 회전을 흡수합니다.
  • 401이 났을 때 무작정 JWKS를 계속 당기면 레이트리밋/장애 증폭이 생길 수 있으니, 라이브러리의 쿨다운/동시성 제어를 활용합니다.

흔한 안티패턴: ‘JWKS를 하루에 한 번만 갱신’

운영에서 종종 보는 구현은 이런 형태입니다.

  • 앱 부팅 시 JWKS 한 번 로드
  • 메모리에 하루 TTL로 캐시
  • kid miss가 나도 “그럴 리 없다” 하고 401

키 회전은 예고 없이 일어날 수 있고(자동 회전), 장애/재배포/멀티리전 등으로 인해 각 인스턴스가 서로 다른 시점의 JWKS를 쥐고 있을 수 있습니다. 결과는 “간헐적 401”입니다.

캐시는 성능을 위해 필요하지만, **정합성(키 회전 반영)**을 깨면 인증은 곧바로 장애가 됩니다.


React(Auth0 SPA SDK) 측에서 확인할 것들

React는 검증 주체가 아니지만, 증상 분석에 도움이 되는 체크는 있습니다.

1) Access Token의 audience가 API로 제대로 발급되는지

React에서 getAccessTokenSilently를 호출할 때 audience를 누락하면, API에서 기대하는 aud와 달라 검증 실패가 날 수 있습니다.

// 예: @auth0/auth0-react
const token = await getAccessTokenSilently({
  authorizationParams: {
    audience: import.meta.env.VITE_AUTH0_AUDIENCE,
    scope: "openid profile email"
  }
});

2) 토큰을 너무 오래 들고 있지 않기

SPA는 토큰을 갱신하며 쓰는 게 일반적이지만, 커스텀 저장소(localStorage 등)에 넣고 장시간 유지하면 회전/만료 이슈가 더 눈에 띄게 됩니다.


운영 관점 디버깅: 로그에 무엇을 남겨야 빠르게 잡히나

401이 뜰 때 다음을 남기면 원인 분리가 빨라집니다.

  • JWT 헤더의 kid (토큰 전체를 남기지 말고 헤더만 디코드)
  • iss, aud 검증 실패 여부
  • JWKS에서 로드한 key들의 kid 목록(요약)
  • JWKS fetch 성공/실패, 마지막 갱신 시각

예: kid만 추출하는 코드(검증 전에 디코드만)

function getKid(token) {
  const [headerB64] = token.split(".");
  const json = Buffer.from(headerB64, "base64url").toString("utf8");
  return JSON.parse(json).kid;
}

이렇게 하면 “JWT의 kid는 새 건데, 우리 서버 JWKS는 옛날 것만 들고 있다”를 즉시 확인할 수 있습니다.


멀티 인스턴스/서버리스에서의 추가 고려사항

  • 인스턴스 로컬 메모리 캐시는 인스턴스마다 다릅니다. 키 회전 직후 트래픽이 특정 인스턴스에 몰리면 그 인스턴스만 401을 뿜을 수 있습니다.
  • Redis 같은 중앙 캐시를 쓰는 방법도 있지만, JWKS는 공개 정보라서 중앙 캐시가 필수는 아닙니다. 오히려 중앙 캐시 장애가 인증 장애로 번질 수 있어, 보통은 라이브러리 캐시 + 재시도 설계로 충분합니다.

인증처럼 “가끔 401”이 치명적인 영역은 다른 스택에서도 유사한 패턴이 반복됩니다. 예를 들어 SecurityContext 누락으로 간헐적 401이 나는 케이스도 비슷하게 비결정적/환경 의존 문제가 많습니다: Spring Boot 3에서 가끔 401? SecurityContext 누락 해결


결론

Auth0 + React 환경에서 JWT 검증이 간헐적으로 실패한다면, “Auth0가 문제인가?”보다 먼저 백엔드의 JWKS 캐시가 키 회전을 따라가도록 설계되어 있는지를 의심하는 게 빠릅니다.

정리하면 다음 3가지만 지키면 재발 확률이 크게 줄어듭니다.

  1. JWKS 캐시 TTL을 과도하게 길게 두지 말 것
  2. kid miss/서명 관련 실패 시 JWKS 재조회 + 1회 재시도를 넣을 것
  3. iss/aud 불일치를 로그로 분리해 원인을 즉시 특정할 것

이미 운영 중이라면, 우선은 kid와 JWKS 갱신 시각을 로그로 남기는 것부터 시작해도 진단 속도가 확 달라집니다.