Node.js에서 jose로 JWT를 검증할 때 가장 안전한 기본형은 jwtVerify() + createRemoteJWKSet() 조합입니다. 이 방식은 단순 디코딩이 아니라 서명 검증 + claims 검증(issuer, audience, exp 등) 을 함께 처리해 주기 때문에 OAuth2/OIDC 서버가 발급한 RS256/ES256 토큰 검증에 적합합니다.
핵심 개념
JWT 검증은 크게 3단계입니다: 토큰 추출, 서명 검증용 키 선택, claims 검증입니다. jose의 createRemoteJWKSet()은 원격 jwks_uri에서 공개키 세트를 가져와 kid, alg, kty, use, key_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()의 핵심 검증 포인트는 issuer, audience, algorithms입니다. 여기에 시간 오차를 허용하려면 clockTolerance를 줄 수 있으며, 예시 문서에서는 1m 같은 clock skew 허용도 사용합니다.
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://auth.example.com/',
audience: 'api://my-service',
algorithms: ,
clockTolerance: '60s'
})
issuer: 토큰 발급자 검증,issclaim과 일치해야 합니다.audience: 이 API를 위한 토큰인지 확인하며audclaim과 일치해야 합니다.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 기반 공개키 검증을 써야 한다고 설명합니다.
운영 팁
프로덕션에서는 다음 원칙이 중요합니다:
issuer,audience,algorithms를 항상 명시합니다.- access token 만료(
exp)를 신뢰하되 너무 긴 수명은 피합니다. - 401과 403을 구분합니다: 토큰 자체 문제는 401, 권한 부족은 403입니다.
- JWKS는 원격 조회를 쓰되, 키 회전이 발생할 수 있음을 전제로 설계합니다.
MCP 서버에 붙이는 형태
MCP 서버에서 jose를 쓸 때도 구조는 같습니다. HTTP 진입점에서 Bearer 토큰을 검증하고, 성공 시 sub, scope, client_id를 요청 컨텍스트에 실어 도구 실행 권한을 나누면 됩니다. 즉, jose는 일반 API뿐 아니라 MCP tool call 보호 계층에도 그대로 재사용할 수 있습니다.
원하시면 다음 답변에서 Keycloak 기준 Node.js 전체 예제, Auth0 기준 예제, 또는 MCP 서버 미들웨어 형태의 완성 코드로 바로 이어서 정리해드릴게요.