- Published on
Auth0+React JWT 검증 실패 - JWKS 캐시·키회전 대응
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
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) 키 회전 직후 “일부 토큰만” 실패하는 이유
키 회전이 일어나면 대개 이런 타임라인이 생깁니다.
- Auth0가 새 키로 서명하기 시작(새
kid등장) - 기존 토큰(옛
kid)은 만료될 때까지 계속 유효할 수 있음 - 백엔드가 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는 공개키라서 자주 받아도 보안상 큰 문제는 없지만, 트래픽/지연/레이트리밋 때문에 캐시가 필요합니다. 문제는 다음입니다.
- TTL이 너무 길다 → 키 회전 반영이 늦다
- 실패 응답을 캐시한다 → 장애가 길어진다
- 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가지만 지키면 재발 확률이 크게 줄어듭니다.
- JWKS 캐시 TTL을 과도하게 길게 두지 말 것
kid miss/서명 관련 실패 시 JWKS 재조회 + 1회 재시도를 넣을 것iss/aud불일치를 로그로 분리해 원인을 즉시 특정할 것
이미 운영 중이라면, 우선은 kid와 JWKS 갱신 시각을 로그로 남기는 것부터 시작해도 진단 속도가 확 달라집니다.