JWKS로 JWT 서명 검증하기
JWT access token을 써본 분이라면 이런 순간을 마주쳤을 겁니다. “토큰이 진짜인지는 서명으로 검증한다는데, 그 서명을 확인할 공개 키는 도대체 어디서 얻지?” 또 “Authorization Server가 키를 바꾸면 Resource Server는 그걸 어떻게 따라가지?”
이 두 질문에 한 번에 답하는 장치가 JWKS(JSON Web Key Set)입니다.
정확한 출처는 JOSE 스펙인 RFC 7517이고, OAuth는 이 조각을 jwks_uri라는 필드로 빌려다 씁니다.
이 글에서는 JWKS 문서의 구조부터 kid 기반 키 로테이션, 캐시 전략, 실무에서 자주 만나는 함정까지 한 자리에 정리합니다.
OAuth 2.0 엔드포인트 제대로 이해하기에서 자체 검증과 introspection의 트레이드오프를, JWT(Json Web Token)에서 토큰 구조 자체를 다뤘으니, 이 글은 그 둘을 잇는 다리로 읽어주시면 좋겠습니다.
JWKS가 풀어주는 문제
Resource Server가 들어온 JWT를 검증하려면 두 가지가 필요합니다.
토큰에 붙은 서명을 만들 때 쓴 알고리즘과, 그 서명을 검증할 공개 키죠.
알고리즘은 JWT 헤더의 alg 필드로 바로 얻을 수 있는데, 공개 키는 그렇게 간단하지 않습니다.
가장 순진한 방법은 Resource Server에 공개 키를 직접 박아두는 거예요. 하지만 AS가 키를 교체하는 순간 RS가 깨지고, 멀티 테넌트 환경이라면 키 관리가 지옥이 됩니다. 그렇다고 매 요청마다 AS에 “이 토큰 유효해?”를 묻는 introspection으로 돌아가면, 자체 검증의 이점인 네트워크 왕복 제거가 무의미해지고요.
JWKS가 이 사이의 절충입니다. AS가 공개 키 꾸러미를 한 URL에 공개하고, RS는 그걸 한 번 받아 캐시해두고 재사용하는 구조예요. 최초 한 번의 네트워크 비용만 지불하면 이후 수많은 요청을 RS 자체 프로세스 안에서 검증할 수 있습니다.
JWKS 문서의 구조
JWKS는 최상위에 keys 배열 하나만 가진 단순한 JSON 문서입니다.
배열의 각 원소가 공개 키 하나에 해당해요.
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"kid": "2026-03-rsa",
"alg": "RS256",
"n": "xGOr-H7A-Efcz...long-base64...",
"e": "AQAB"
},
{
"kty": "EC",
"use": "sig",
"kid": "2026-03-ec",
"alg": "ES256",
"crv": "P-256",
"x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
"y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0"
}
]
}
각 키의 핵심 필드는 다음과 같습니다.
kty— Key Type.RSA,EC,OKP중 하나. 키의 수학적 종류를 지정합니다use— 용도.sig(서명 검증)와enc(암호화) 둘 중 하나. JWT access token 검증에서는sig만 씁니다kid— Key ID. 이 키를 식별하는 문자열. JWT 헤더의kid와 매칭하는 데 쓰입니다. 뒤에 자세히 다룹니다alg— 이 키가 쓰이는 서명 알고리즘.RS256,ES256,EdDSA등. JWT 헤더의alg와 일치해야 합니다- 키 본체 파라미터 —
kty에 따라 달라집니다. RSA는n(modulus)·e(exponent), EC는crv·x·y, OKP(Ed25519 등)는crv·x
키 본체 파라미터는 Base64url로 인코딩된 값으로 실려 있는데, 직접 디코딩해 다룰 일은 거의 없습니다.
jose 계열 라이브러리에 JWKS JSON을 그대로 넘기면 알아서 파싱해주니까요.
kid로 올바른 키 고르기
JWKS에 키가 여럿 들어있을 수 있습니다. 그럼 하나의 JWT가 들어왔을 때 어느 키로 검증해야 할까요?
이 매칭을 담당하는 게 kid(Key ID)입니다.
JWT의 헤더에도 kid가 실려오고, JWKS의 각 키에도 kid가 있어서, 두 값을 맞춰 같은 키를 골라냅니다.
{
"alg": "RS256",
"typ": "JWT",
"kid": "2026-03-rsa"
}
위 JWT가 들어오면 RS는 JWKS에서 kid가 "2026-03-rsa"인 원소를 찾아 거기 있는 n·e로 서명을 검증합니다.
만약 JWKS 어디에도 해당 kid가 없다면 두 가지 가능성을 의심해야 해요.
- AS가 새 키로 교체했는데 RS의 JWKS 캐시가 갱신되지 않음
- 악의적으로 조작된 JWT
보통은 1번일 확률이 압도적으로 높습니다.
그래서 “JWKS에서 kid를 못 찾으면 한 번만 캐시를 강제 갱신해본다”가 표준 구현 패턴이에요.
kid 없는 JWT도 스펙상 허용되긴 하는데, 이 경우 RS는 JWKS의 모든 키에 대해 검증을 시도하거나 사전 약속된 단일 키를 써야 합니다.
실무에서는 AS에 kid 포함을 요구하는 게 기본이에요. 운영이 훨씬 단순해집니다.
jwks_uri: JWKS를 어디서 받나
RS가 JWKS 문서를 얻는 공식 경로는 AS Metadata의 jwks_uri 필드입니다.
AS는 자기 정보를 .well-known/oauth-authorization-server에 JSON으로 공개하고, 그 안의 jwks_uri가 JWKS 문서 주소를 가리켜요.
{
"issuer": "https://as.example.com",
"jwks_uri": "https://as.example.com/.well-known/jwks.json"
}
여기서 .well-known/jwks.json 경로는 관습일 뿐 스펙이 강제하지 않습니다.
공급자별로 실제 경로는 제각각이에요.
- Auth0:
https://{tenant}.auth0.com/.well-known/jwks.json - Google:
https://www.googleapis.com/oauth2/v3/certs - AWS Cognito:
https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/jwks.json - Microsoft Entra:
https://login.microsoftonline.com/{tenantId}/discovery/v2.0/keys
그래서 RS는 절대 .well-known/jwks.json을 하드코딩하지 말고, AS Metadata에서 jwks_uri를 읽어 쓰는 게 원칙입니다.
공급자를 바꾸거나 멀티 테넌트로 확장할 때 이 한 줄이 운명을 가릅니다.
자체 검증의 전체 흐름
지금까지 배운 조각을 실제 검증 순서로 이어붙이면 이렇습니다.
- RS가 기동 시점(또는 최초 요청 시점)에 AS Metadata를 조회해
jwks_uri를 얻는다 jwks_uri에 GET 요청을 보내 JWKS 문서를 받는다- JWKS 문서를 메모리에 캐시한다
- JWT가 들어오면 헤더의
kid를 꺼내 JWKS에서 같은kid를 가진 키를 찾는다 - 해당 키의 파라미터로 JWT 서명을 검증한다
- 서명이 유효하면 페이로드의
iss,aud,exp,nbf를 검증한다
중요한 건 4~6단계에 네트워크 호출이 없다는 점입니다. 캐시된 JWKS로 서명 검증을 수행하고, 페이로드에 박혀 있는 정보로 claim 검증을 수행해요. 바로 이 지점이 introspection 대비 자체 검증의 핵심 이점입니다.
import { createRemoteJWKSet, jwtVerify } from "jose";
// JWKS 자동 캐시 & 갱신
const JWKS = createRemoteJWKSet(
new URL("https://as.example.com/.well-known/jwks.json"),
);
// 서명·claim 검증
const { payload } = await jwtVerify(token, JWKS, {
issuer: "https://as.example.com",
audience: "https://api.example.com",
});
console.log(payload.sub, payload.exp);
createRemoteJWKSet이 내부적으로 JWKS를 페치·캐시·갱신까지 알아서 처리합니다.
kid가 캐시에 없으면 한 번 재페치를 시도하고, 그래도 없으면 에러를 내는 일반적인 구현이에요.
캐시와 키 로테이션
JWKS 운영의 절반은 캐시 관리에 달려 있습니다. 두 힘이 서로 반대 방향으로 작용하거든요.
- 오래 캐시할수록 좋다 → 네트워크 왕복과 AS 부하를 줄이기 위해
- 짧게 캐시할수록 좋다 → 키가 교체됐을 때 빨리 반영하기 위해
실무에서 자리 잡은 절충점은 다음과 같습니다.
- 기본 캐시 수명은 수 시간 단위 — 대부분의 IdP가 JWKS 응답에
Cache-Control: max-age=...헤더를 실어 보내고, 이 값을 따르는 게 안전합니다. AS가 공식적으로 광고하는 교체 주기와 맞춰져 있거든요 kid미스 시 강제 재페치 — 캐시된 JWKS에 없는kid를 만나면 TTL 만료 전이라도 한 번 다시 받아옵니다. 이게 AS의 키 교체를 빨리 따라잡는 핵심 장치예요- 쿨다운 필수 —
kid미스마다 매번 재페치하면 악의적인 JWT 폭풍이 DDoS 통로가 되니, 같은kid미스를 짧은 시간 반복하면 재페치를 건너뛰는 쿨다운을 둡니다
AS 쪽의 키 로테이션은 보통 다음 패턴을 따릅니다.
- 새 키를 생성하고 JWKS에 기존 키와 함께 게시한다 (두 키 공존 기간)
- 일정 시간 동안 여전히 기존 키로 서명한다
- 새 키로 서명을 전환한다
- 기존 키로 서명된 토큰의 수명이 모두 끝난 뒤에 JWKS에서 기존 키를 제거한다
2단계의 공존 기간이 핵심입니다.
RS가 캐시를 갱신하기 전에 새 키로 서명된 토큰이 먼저 들어오는 걸 막아주거든요.
AS가 이 기간 없이 바로 교체해버리면 RS들이 일제히 kid 미스를 겪고, AS에 JWKS 재요청 폭탄이 터집니다.
실무에서 자주 만나는 함정
몇 가지 전형적인 사고 패턴을 미리 알아두면 디버깅이 훨씬 빨라집니다.
.well-known/jwks.json하드코딩 — 경로는 공급자마다 다르다는 걸 앞서 봤습니다. 반드시 AS Metadata에서jwks_uri를 읽어 쓰세요alg고정 없이 검증 — JWT 라이브러리에alg를 지정하지 않으면alg: none공격이나 키 혼동 공격에 노출될 수 있습니다. AS가 허용한 알고리즘 목록으로 엄격히 제한하세요iss·aud검증 생략 — 서명만 맞으면 통과시키는 구현이 꽤 많습니다. 다른 테넌트·다른 audience용 토큰을 재사용하는 공격을 막으려면 반드시iss와aud까지 검증해야 합니다- JWKS 페치 타임아웃 미설정 — AS가 느려지면 RS의 검증 스레드가 줄줄이 블로킹됩니다. 짧은 타임아웃과 기존 캐시 유지 전략(
stale-while-revalidate스타일)을 함께 둬야 안전합니다 - 여러 테넌트의 JWKS를 한 캐시에 뒤섞기 — 멀티 테넌트 AS에서는
issuer마다 JWKS가 다릅니다. 캐시 키를issuer단위로 분리하지 않으면 교차 오염이 생길 수 있어요
마치며
JWKS는 “공개 키를 공개 URL로 배포한다”는 단순한 아이디어지만, 이 단순함이 OAuth 2.1 시대 자체 검증 아키텍처 전체를 떠받치고 있습니다.
kid로 키를 식별하고, AS가 키 공존 기간을 주며 로테이션하고, RS는 캐시와 kid 미스 재페치 전략으로 AS를 따라간다 — 이 네 가지만 머리에 담아두면 실무에서 JWKS 관련 이슈의 대부분을 설명할 수 있어요.
JWKS는 JOSE 레이어의 조각이라 OAuth와는 층위가 다르지만, jwks_uri라는 다리를 통해 OAuth Metadata와 연결됩니다.
OAuth 2.0 메타데이터와 엔드포인트 동적 발견에서 jwks_uri가 어떻게 발견 흐름에 끼어드는지 확인해보시면 그림이 완성될 겁니다.
MCP Authentication처럼 자체 검증을 전제로 설계된 최신 프로토콜에서는 이 메커니즘이 특히 중요해지고요.
더 자세한 내용은 RFC 7517 - JSON Web Key (JWK)를 참고하세요.
This work is licensed under
CC BY 4.0