はじめに
APIサーバーを作っていると、必ずぶつかるのが「誰がログインしているか、どうやって判断するか」という問題です。セッションをサーバー側に保存する方式は分かりやすい反面、複数サーバーへのスケールやモバイルアプリとの連携で扱いにくくなりがちです。
そこで登場するのがJWT(JSON Web Token)です。ユーザー情報や権限を暗号署名付きのトークンに詰め込み、クライアント側で保持してもらう。サーバーはトークンの署名さえ検証すればよく、セッションストアを持つ必要がありません。
この仕組みをNode.jsで実装するとき、事実上の標準として使われ続けているのがjsonwebtokenです。Auth0が開発し、9.x系まで安定してメンテナンスされているライブラリで、sign・verify・decodeというシンプルな3つの関数だけでJWTのライフサイクルをすべてカバーできます。
jsonwebtokenとは
jsonwebtokenは、RFC 7519で定義されたJSON Web Tokenの仕様に沿ってトークンの生成と検証を行うNode.js向けライブラリです。HS256のような共通鍵方式から、RS256・ES256といった公開鍵暗号方式まで、幅広い署名アルゴリズムに対応しています。
主な特徴
- APIがシンプル -
jwt.sign()でトークン発行、jwt.verify()で検証、jwt.decode()で中身の確認と、役割ごとに関数が分かれていて迷わない - 豊富なオプション - 有効期限(
expiresIn)、発行者(issuer)、対象者(audience)などJWTの標準クレームをオプション一つで扱える - 対称鍵・非対称鍵の両対応 - HS256による共有シークレットでの署名だけでなく、RS256による秘密鍵・公開鍵ペアでの署名検証もサポートしている
なお、jsonwebtokenはNode.jsのcryptoモジュールに依存したサーバーサイド専用のライブラリです。ブラウザ上で直接動かすことは想定されていないため、この記事のコード例もNode.js環境での実行を前提にしています。
インストール
npm install jsonwebtoken
TypeScriptで型情報を使いたい場合は、あわせて型定義パッケージも入れておくと補完が効くようになります。
npm install --save-dev @types/jsonwebtoken
基本的な使い方
jsonwebtokenの基本は、トークンを発行するsign、トークンを検証するverify、そして署名を検証せず中身だけを覗くdecodeの3つです。
const jwt = require('jsonwebtoken');
// トークンの発行(HS256、共有シークレットを使用)
const token = jwt.sign(
{ userId: 42, role: 'member' },
'my-secret-key',
{ expiresIn: '1h' }
);
console.log(token);
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
// トークンの検証
try {
const decoded = jwt.verify(token, 'my-secret-key');
console.log(decoded); // { userId: 42, role: 'member', iat: ..., exp: ... }
} catch (err) {
console.error('検証に失敗しました:', err.message);
}
jwt.verify()は署名が不正だったり有効期限が切れていたりすると例外を投げます。TokenExpiredErrorやJsonWebTokenErrorといった専用のエラークラスが用意されているので、err.nameで分岐すれば「期限切れだから再ログインを促す」「署名が不正だから拒否する」といった処理を書き分けられます。
実践的なユースケース
Expressでのログイン認証
もっとも典型的な使い方が、ログインAPIでのトークン発行と、以降のリクエストでの検証です。ログイン成功時にjwt.sign()でアクセストークンを発行し、クライアントはそれをAuthorizationヘッダーに載せて送ってきます。サーバー側はミドルウェアでjwt.verify()を呼び、通ったリクエストだけを処理します。
const express = require('express');
const jwt = require('jsonwebtoken');
const app = express();
app.use(express.json());
const SECRET = process.env.JWT_SECRET;
app.post('/login', (req, res) => {
// ここでID・パスワードの検証を行う想定
const user = { id: 1, name: 'takuma' };
const token = jwt.sign({ sub: user.id, name: user.name }, SECRET, {
expiresIn: '15m',
});
res.json({ token });
});
function authenticate(req, res, next) {
const header = req.headers.authorization; // "Bearer <token>"
const token = header && header.split(' ')[1];
if (!token) return res.status(401).json({ error: 'トークンがありません' });
jwt.verify(token, SECRET, (err, payload) => {
if (err) return res.status(403).json({ error: 'トークンが無効です' });
req.user = payload;
next();
});
}
app.get('/me', authenticate, (req, res) => {
res.json({ user: req.user });
});
有効期限を15分のように短く切っているのがポイントです。トークンが漏れたときの被害を最小限にするため、アクセストークンは短命にし、後述するリフレッシュトークンで延長する構成が定番になっています。
有効期限とリフレッシュトークン
アクセストークンを短命にすると、そのぶんユーザーは頻繁に再ログインを求められることになります。そこで、有効期限の長い「リフレッシュトークン」を別途発行し、アクセストークンが切れたらリフレッシュトークンと引き換えに新しいアクセストークンを発行する、という二段構えがよく使われます。
const ACCESS_SECRET = process.env.ACCESS_SECRET;
const REFRESH_SECRET = process.env.REFRESH_SECRET;
function issueTokens(userId) {
const accessToken = jwt.sign({ sub: userId }, ACCESS_SECRET, {
expiresIn: '15m',
});
const refreshToken = jwt.sign({ sub: userId }, REFRESH_SECRET, {
expiresIn: '30d',
});
return { accessToken, refreshToken };
}
app.post('/token/refresh', (req, res) => {
const { refreshToken } = req.body;
jwt.verify(refreshToken, REFRESH_SECRET, (err, payload) => {
if (err) {
return res.status(403).json({ error: 'リフレッシュトークンが無効です' });
}
const accessToken = jwt.sign({ sub: payload.sub }, ACCESS_SECRET, {
expiresIn: '15m',
});
res.json({ accessToken });
});
});
アクセストークンとリフレッシュトークンでシークレットを分けているのが重要です。万が一アクセストークンの検証ロジックに不備があっても、リフレッシュトークンの発行ロジックまでは影響が及びません。
RS256による非対称鍵での署名検証
HS256は発行側と検証側が同じシークレットを共有する必要があるため、マイクロサービス構成で複数のサービスがトークンを検証する場合には不向きです。RS256を使えば、トークンの発行は秘密鍵を持つ認証サーバーだけが行い、各サービスは公開鍵だけを持って検証できます。
const fs = require('fs');
const jwt = require('jsonwebtoken');
const privateKey = fs.readFileSync('private.pem');
const publicKey = fs.readFileSync('public.pem');
// 発行側(秘密鍵を持つ認証サーバー)
const token = jwt.sign({ sub: 'user-1' }, privateKey, {
algorithm: 'RS256',
expiresIn: '1h',
});
// 検証側(公開鍵だけを持つ各マイクロサービス)
const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
console.log(decoded.sub); // 'user-1'
検証側でalgorithms: ['RS256']を明示している点に注目してください。アルゴリズムを指定せずに検証すると、悪意のある送信者がヘッダーのalgをnoneや別のアルゴリズムに書き換えて検証をすり抜けようとする攻撃(アルゴリズム混同攻撃)のリスクが生まれます。jsonwebtokenでは想定するアルゴリズムをalgorithmsオプションで明示的に絞り込むことが、安全な運用の前提になります。
issuer・audienceによるクレーム検証
複数のサービスやクライアントが同じ認証基盤を使う場合、「このトークンはどこが発行したか」「どのサービス向けか」まで検証したいことがあります。jsonwebtokenはissuer(発行者)とaudience(対象者)を標準クレームとしてサポートしており、verifyのオプションに渡すだけで不一致を弾けます。
const token = jwt.sign({ sub: 'user-1' }, SECRET, {
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
expiresIn: '1h',
});
try {
const decoded = jwt.verify(token, SECRET, {
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
});
console.log(decoded.sub);
} catch (err) {
// issuerやaudienceが一致しない場合もここでエラーになる
console.error(err.name, err.message);
}
同じシークレットを複数のサービスで共有している場合でも、audienceを見て「自分宛てではないトークンは拒否する」という制御ができるようになります。認証サーバーとリソースサーバーを分離する構成では、これらのクレームを検証しておくことをおすすめします。
まとめ
jsonwebtokenは、sign・verify・decodeという3つの関数を軸に、トークンの発行から有効期限管理、非対称鍵での署名検証、標準クレームの検証まで一貫して扱える、Node.jsにおけるJWT実装の定番ライブラリです。
シンプルなAPIの裏側には、有効期限切れの検知やアルゴリズムの明示的な指定など、安全にトークン認証を運用するための仕組みがしっかり用意されています。まずは短命なアクセストークンとリフレッシュトークンの組み合わせから試してみて、サービスが増えてきたらRS256での鍵分離やissuer・audienceの検証を取り入れていくと、無理なくスケールする認証基盤を作れるはずです。