JWKS로 JWT 서명 검증하기

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 문서입니다. 배열의 각 원소가 공개 키 하나에 해당해요.

jwks.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가 있어서, 두 값을 맞춰 같은 키를 골라냅니다.

JWT 헤더 예시
{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "2026-03-rsa"
}

위 JWT가 들어오면 RS는 JWKS에서 kid"2026-03-rsa"인 원소를 찾아 거기 있는 n·e로 서명을 검증합니다. 만약 JWKS 어디에도 해당 kid가 없다면 두 가지 가능성을 의심해야 해요.

  1. AS가 새 키로 교체했는데 RS의 JWKS 캐시가 갱신되지 않음
  2. 악의적으로 조작된 JWT

보통은 1번일 확률이 압도적으로 높습니다. 그래서 “JWKS에서 kid를 못 찾으면 한 번만 캐시를 강제 갱신해본다”가 표준 구현 패턴이에요.

kid 없는 JWT도 스펙상 허용되긴 하는데, 이 경우 RS는 JWKS의 모든 키에 대해 검증을 시도하거나 사전 약속된 단일 키를 써야 합니다. 실무에서는 AS에 kid 포함을 요구하는 게 기본이에요. 운영이 훨씬 단순해집니다.

jwks_uri: JWKS를 어디서 받나

RS가 JWKS 문서를 얻는 공식 경로는 AS Metadatajwks_uri 필드입니다. AS는 자기 정보를 .well-known/oauth-authorization-server에 JSON으로 공개하고, 그 안의 jwks_uri가 JWKS 문서 주소를 가리켜요.

AS Metadata 일부
{
  "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를 읽어 쓰는 게 원칙입니다. 공급자를 바꾸거나 멀티 테넌트로 확장할 때 이 한 줄이 운명을 가릅니다.

자체 검증의 전체 흐름

지금까지 배운 조각을 실제 검증 순서로 이어붙이면 이렇습니다.

  1. RS가 기동 시점(또는 최초 요청 시점)에 AS Metadata를 조회해 jwks_uri를 얻는다
  2. jwks_uri에 GET 요청을 보내 JWKS 문서를 받는다
  3. JWKS 문서를 메모리에 캐시한다
  4. JWT가 들어오면 헤더의 kid를 꺼내 JWKS에서 같은 kid를 가진 키를 찾는다
  5. 해당 키의 파라미터로 JWT 서명을 검증한다
  6. 서명이 유효하면 페이로드의 iss, aud, exp, nbf를 검증한다

중요한 건 4~6단계에 네트워크 호출이 없다는 점입니다. 캐시된 JWKS로 서명 검증을 수행하고, 페이로드에 박혀 있는 정보로 claim 검증을 수행해요. 바로 이 지점이 introspection 대비 자체 검증의 핵심 이점입니다.

Node.js (jose 라이브러리) 예시
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 쪽의 키 로테이션은 보통 다음 패턴을 따릅니다.

  1. 새 키를 생성하고 JWKS에 기존 키와 함께 게시한다 (두 키 공존 기간)
  2. 일정 시간 동안 여전히 기존 키로 서명한다
  3. 새 키로 서명을 전환한다
  4. 기존 키로 서명된 토큰의 수명이 모두 끝난 뒤에 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용 토큰을 재사용하는 공격을 막으려면 반드시 issaud까지 검증해야 합니다
  • 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 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord