Node.js jose로 JWT 토큰 검증 상세 가이드

Node.js에서 jose로 JWT를 검증할 때 가장 안전한 기본형은 jwtVerify() + createRemoteJWKSet() 조합입니다. 이 방식은 단순 디코딩이 아니라 서명 검증 + claims 검증(issuer, audience, exp 등) 을 함께 처리해 주기 때문에 OAuth2/OIDC 서버가 발급한 RS256/ES256 토큰 검증에 적합합니다.

핵심 개념

JWT 검증은 크게 3단계입니다: 토큰 추출서명 검증용 키 선택claims 검증입니다. jose의 createRemoteJWKSet()은 원격 jwks_uri에서 공개키 세트를 가져와 kidalgktyusekey_ops를 고려해 적절한 검증 키를 고르도록 설계되어 있습니다.

설치와 전제

jose는 JWT, JWS, JWE, JWK, JWKS를 폭넓게 지원하는 라이브러리이며, 최신 버전은 ESM 중심으로 제공됩니다. 따라서 Node.js에서는 보통 type: "module" 환경 또는 ESM import 구문을 쓰는 것이 자연스럽습니다.

npm install jose
import * as jose from 'jose'

가장 기본적인 검증 예제

공식 문서 예시의 핵심 형태는 아래와 같습니다. JWKS는 원격 공개키 집합을 가리키고, jwtVerify()는 이 키 세트를 사용해 토큰의 서명과 claims를 함께 검증합니다.

import { createRemoteJWKSet, jwtVerify } from 'jose'

const JWKS = createRemoteJWKSet(
 new URL('https://auth.example.com/.well-known/jwks.json')
)

const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
 issuer: 'https://auth.example.com/',
 audience: 'api://my-service'
})

console.log(protectedHeader)
console.log(payload)

이 패턴은 Auth0, Keycloak, Cognito, Okta처럼 OIDC/JWKS를 제공하는 인증 서버에 거의 그대로 적용할 수 있습니다.

Express 미들웨어 예제

실무에서는 토큰 검증을 미들웨어로 분리하는 것이 일반적입니다. 아래 예시는 Authorization: Bearer <token> 헤더를 파싱하고, 검증 성공 시 req.auth에 payload와 header를 저장하는 방식입니다.

import express from 'express'
import { createRemoteJWKSet, jwtVerify } from 'jose'

const app = express()

const JWKS = createRemoteJWKSet(
 new URL('https://auth.example.com/.well-known/jwks.json')
)

async function verifyAccessToken(req, res, next) {
 try {
 const authHeader = req.headers.authorization || ''
 const = authHeader.split(' ')

 if (scheme !== 'Bearer' || !token) {
 return res.status(401).json({ error: 'missing_bearer_token' })
 }

 const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
 issuer: 'https://auth.example.com/',
 audience: 'api://my-service',
 algorithms: 
 })

 req.auth = { payload, protectedHeader }
 next()
 } catch (err) {
 return res.status(401).json({
 error: 'invalid_token',
 message: err.message
 })
 }
}

app.get('/api/me', verifyAccessToken, (req, res) => {
 res.json({
 sub: req.auth.payload.sub,
 scope: req.auth.payload.scope
 })
})

app.listen(3000)

옵션별 의미

jwtVerify()의 핵심 검증 포인트는 issueraudiencealgorithms입니다. 여기에 시간 오차를 허용하려면 clockTolerance를 줄 수 있으며, 예시 문서에서는 1m 같은 clock skew 허용도 사용합니다.

const { payload } = await jwtVerify(token, JWKS, {
 issuer: 'https://auth.example.com/',
 audience: 'api://my-service',
 algorithms: ,
 clockTolerance: '60s'
})
  • issuer: 토큰 발급자 검증, iss claim과 일치해야 합니다.
  • audience: 이 API를 위한 토큰인지 확인하며 aud claim과 일치해야 합니다.
  • algorithms: 허용 알고리즘을 제한해 알고리즘 혼동 공격을 줄입니다.
  • clockTolerance: 서버 시간 차이로 인한 오검증을 줄이는 보정값입니다.

payload와 protected header

검증이 끝나면 payload와 protectedHeader를 받을 수 있습니다. 일반적으로 사용자 식별은 payload.sub, 권한은 payload.scope 또는 payload.roles, 키 식별은 protectedHeader.kid, 알고리즘은 protectedHeader.alg를 확인합니다.

const { payload, protectedHeader } = await jwtVerify(token, JWKS, options)

console.log(payload.sub)
console.log(payload.scope)
console.log(protectedHeader.kid)
console.log(protectedHeader.alg)

scope 검증 추가

JWT가 유효해도 필요한 scope가 없으면 접근을 막아야 합니다. 이때는 검증 이후 별도로 scope 체크를 수행합니다.

function requireScope(required) {
 return (req, res, next) => {
 const scopes = (req.auth?.payload?.scope || '').split(' ')
 if (!scopes.includes(required)) {
 return res.status(403).json({
 error: 'insufficient_scope',
 required
 })
 }
 next()
 }
}

app.post(
 '/api/messages',
 verifyAccessToken,
 requireScope('messages:write'),
 (req, res) => {
 res.json({ ok: true })
 }
)

로컬 JWK/JWKS 검증

원격 JWKS 대신 로컬 JWK를 직접 가져와 검증할 수도 있습니다. jose는 JWK import도 지원하며, 예시 문서에서는 importJWK()를 통해 공개키를 만들어 jwtVerify()에 넣는 방법을 보여줍니다.

import { importJWK, jwtVerify } from 'jose'

const jwk = {
 kty: 'RSA',
 n: '...',
 e: 'AQAB',
 alg: 'RS256',
 use: 'sig'
}

const publicKey = await importJWK(jwk, 'RS256')

const { payload } = await jwtVerify(token, publicKey, {
 algorithms: ,
 issuer: 'https://auth.example.com/',
 audience: 'api://my-service'
})

이 방식은 테스트 환경이나 키가 고정된 내부 시스템에서 유용하지만, 일반적인 OAuth2/OIDC 환경에서는 키 회전을 자동 따라가는 원격 JWKS 방식이 더 적합합니다.

여러 키가 동시에 매칭될 때

공식 createRemoteJWKSet() 문서에는 여러 키가 동시에 매칭되는 경우를 처리하는 고급 예제가 있습니다. 이때 ERR_JWKS_MULTIPLE_MATCHING_KEYS가 나면 반복적으로 후보 키를 돌며 서명 검증을 시도할 수 있습니다.

import * as jose from 'jose'

const JWKS = jose.createRemoteJWKSet(
 new URL('https://auth.example.com/.well-known/jwks.json')
)

const options = {
 issuer: 'https://auth.example.com/',
 audience: 'api://my-service'
}

const { payload, protectedHeader } = await jose
 .jwtVerify(token, JWKS, options)
 .catch(async (error) => {
 if (error?.code === 'ERR_JWKS_MULTIPLE_MATCHING_KEYS') {
 for await (const publicKey of error) {
 try {
 return await jose.jwtVerify(token, publicKey, options)
 } catch (innerError) {
 if (innerError?.code === 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED') {
 continue
 }
 throw innerError
 }
 }
 throw new jose.errors.JWSSignatureVerificationFailed()
 }
 throw error
 })

대부분의 서비스에서는 흔치 않지만, 복잡한 키 구성이나 회전 시점 문제를 다룰 때 참고할 만한 패턴입니다.

자주 나는 오류

가장 흔한 실수는 디코딩만 하고 검증했다고 착각하는 것secret으로 RS256 토큰을 검증하려는 것issuer/audience를 빼먹는 것입니다. Auth0 커뮤니티 예시에서도 RS256 토큰은 대칭 secret이 아니라 JWKS 기반 공개키 검증을 써야 한다고 설명합니다.

운영 팁

프로덕션에서는 다음 원칙이 중요합니다:

  • issueraudiencealgorithms를 항상 명시합니다.
  • access token 만료(exp)를 신뢰하되 너무 긴 수명은 피합니다.
  • 401과 403을 구분합니다: 토큰 자체 문제는 401, 권한 부족은 403입니다.
  • JWKS는 원격 조회를 쓰되, 키 회전이 발생할 수 있음을 전제로 설계합니다.

MCP 서버에 붙이는 형태

MCP 서버에서 jose를 쓸 때도 구조는 같습니다. HTTP 진입점에서 Bearer 토큰을 검증하고, 성공 시 subscopeclient_id를 요청 컨텍스트에 실어 도구 실행 권한을 나누면 됩니다. 즉, jose는 일반 API뿐 아니라 MCP tool call 보호 계층에도 그대로 재사용할 수 있습니다.

원하시면 다음 답변에서 Keycloak 기준 Node.js 전체 예제Auth0 기준 예제, 또는 MCP 서버 미들웨어 형태의 완성 코드로 바로 이어서 정리해드릴게요.

댓글 남기기