リンドくんたなべ先生、「JWT」ってよく聞くんですけど、これって何なんですか?セキュリティに関係あるんですよね?
たなべJWTは「JSON Web Token(ジェイソン・ウェブ・トークン)」の略で、Webアプリケーションでユーザー認証を行うための仕組みなんだ。
でも、便利な反面、使い方を間違えると大きなセキュリティリスクになるんだよ。
現代のWeb開発において、ユーザー認証は避けて通れない重要な要素です。
その中でもJWT(JSON Web Token)は、特にAPI開発やモバイルアプリとの連携で広く使われている技術です。
しかし、JWTは便利な一方で、正しく理解せずに使うと深刻なセキュリティ問題を引き起こす可能性があります。
特に「一度発行したトークンを無効化できない」という特性は、多くの開発者を悩ませています。
この記事では、セキュリティ学習を始めたばかりの方でも理解できるよう、JWTの基本的な仕組みから安全な実装方法、そしてトークンを無効化するための「ブラックリスト戦略」まで、実践的に解説していきます。
JWTとは何か?基本的な仕組みを理解しよう
リンドくんそもそもJWTって、どういう仕組みなんですか?普通のログインとは何が違うんでしょう?
たなべ従来のセッション管理とは違って、JWTはサーバー側で状態を保存しないのが大きな特徴なんだ。トークン自体に必要な情報が含まれているから、スケーラブルなシステムを作りやすいんだよ。
JWTの構造
JWTは3つの部分から構成されています。それぞれがドット(.)で区切られており、以下のような構造になっています。
ヘッダー.ペイロード.署名
実際のJWTは次のような文字列になります。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0NTY3ODkwIiwibmFtZSI6IuOCv+ODiuODmeWFiOeUnyIsImlhdCI6MTUxNjIzOTAyMn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
一見するとランダムな文字列に見えますが、実は意味のある情報が含まれているのです。
3つのパートの役割
1. ヘッダー(Header)
トークンのタイプと使用するハッシュアルゴリズムを指定します。
{
"alg": "HS256",
"typ": "JWT"
}2. ペイロード(Payload)
実際のデータ(クレームと呼ばれる)が含まれます。ユーザーIDや権限情報などを格納します。
{
"userId": "1234567890",
"name": "タナベ",
"role": "admin",
"iat": 1516239022,
"exp": 1516242622
}重要なポイント
ペイロードは暗号化されていないため、機密情報(パスワードなど)を含めてはいけません。
Base64でエンコードされているだけなので、誰でも簡単にデコードできます。
3. 署名(Signature)
ヘッダーとペイロードを組み合わせて、秘密鍵でハッシュ化したものです。これにより、トークンが改ざんされていないことを検証できます。
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)なぜJWTが使われるのか
JWTが広く使われる理由には、以下のようなメリットがあります。
- ステートレスな認証 - サーバー側でセッション情報を保持する必要がない
- スケーラビリティ - 複数のサーバー間でセッション共有が不要
- クロスドメイン対応 - APIサーバーと認証サーバーを分離できる
- モバイルアプリとの相性 - クッキーに依存しない認証が可能
ただし、これらのメリットと引き換えに、一度発行したトークンを無効化できないという課題も抱えています。この問題については後ほど詳しく解説します。
JWTを使った認証の基本的な流れ
リンドくん実際にJWTってどうやって使うんですか?ログインからAPIアクセスまでの流れを知りたいです。
たなべ基本的な流れはシンプルだよ。
ログイン時にトークンを発行して、その後のリクエストでトークンを送信するだけなんだ。具体的に見ていこう。
認証フローの全体像
JWT認証の典型的な流れは以下のようになります。
- ログインリクエスト - ユーザーが認証情報(メールアドレスとパスワード)を送信
- トークン発行 - サーバーが認証に成功したらJWTを生成して返却
- トークン保存 - クライアント側でトークンを保存(通常はlocalStorageやcookie)
- APIリクエスト - 保護されたAPIにアクセスする際、トークンをヘッダーに含めて送信
- トークン検証 - サーバーがトークンの署名を検証し、有効であればリクエストを処理
Node.jsでのJWT生成
実際のコード例を見てみましょう。ここではNode.jsとExpressを使った例を紹介します。
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
// ログインエンドポイント
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
// ユーザー情報の取得(データベースから)
const user = await User.findOne({ email });
// パスワードの検証
if (!user || !await bcrypt.compare(password, user.password)) {
return res.status(401).json({ message: '認証に失敗しました' });
}
// JWTの生成
const token = jwt.sign(
{
userId: user.id,
email: user.email,
role: user.role
},
process.env.JWT_SECRET, // 環境変数から秘密鍵を取得
{ expiresIn: '1h' } // 1時間で期限切れ
);
res.json({ token });
});トークンの検証ミドルウェア
発行したトークンを検証するミドルウェアの実装例です。
const verifyToken = (req, res, next) => {
// ヘッダーからトークンを取得
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // "Bearer TOKEN"
if (!token) {
return res.status(401).json({ message: 'トークンが提供されていません' });
}
try {
// トークンの検証
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded; // デコードした情報をリクエストに追加
next();
} catch (error) {
return res.status(403).json({ message: '無効なトークンです' });
}
};
// 保護されたエンドポイント
app.get('/api/profile', verifyToken, (req, res) => {
res.json({
userId: req.user.userId,
email: req.user.email,
role: req.user.role
});
});クライアント側での使用例
フロントエンド(JavaScript)でのトークンの使用例です。
// ログイン処理
async function login(email, password) {
const response = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email, password })
});
const data = await response.json();
// トークンをlocalStorageに保存
localStorage.setItem('token', data.token);
}
// 保護されたAPIへのリクエスト
async function getProfile() {
const token = localStorage.getItem('token');
const response = await fetch('/api/profile', {
headers: {
'Authorization': `Bearer ${token}`
}
});
return await response.json();
}このように、JWTを使った認証は比較的シンプルに実装できます。しかし、セキュリティを考慮すると、さらに多くの対策が必要になってきます。
次のセクションでは、JWTの課題とその対策について見ていきましょう。
JWTの課題 - トークンを無効化できない問題
リンドくんあれ?でも、ユーザーがログアウトしたときって、トークンはどうなるんですか?
たなべそれがJWTの大きな課題なんだ。
一度発行したトークンは、期限が切れるまで有効なままなんだよ。つまり、ログアウトボタンを押しても、技術的にはトークンは使えてしまうんだ。
ステートレスであることの代償
JWTの最大の特徴である「ステートレス」は、同時に大きな課題でもあります。サーバー側でトークンの状態を管理していないため、以下のような問題が発生します。
- ログアウトができない - クライアント側でトークンを削除しても、トークン自体は有効
- 権限変更が即座に反映されない - 管理者権限を剥奪しても、トークンが期限切れになるまで有効
- 盗まれたトークンを無効化できない - セキュリティ侵害が発覚しても対処できない
具体的なリスクシナリオ
実際にどのような問題が起こり得るか、具体例で見てみましょう。
シナリオ1 ログアウト後の不正アクセス
// ユーザーがログアウト
function logout() {
localStorage.removeItem('token'); // トークンを削除
// しかし...
}
// 悪意のある第三者が事前にトークンをコピーしていた場合
const stolenToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...";
// このトークンは期限切れまで使用可能
fetch('/api/sensitive-data', {
headers: {
'Authorization': `Bearer ${stolenToken}`
}
});
// => 成功してしまう!シナリオ2 権限変更の遅延
あるユーザーの管理者権限を剥奪したとします。
// データベース上でユーザーの権限を変更
await User.update({ id: userId }, { role: 'user' });
// しかし、すでに発行されているJWTには古い情報が含まれている
const oldToken = jwt.sign(
{
userId: userId,
role: 'admin' // まだ管理者権限がある!
},
secret,
{ expiresIn: '24h' }
);
// 最大24時間、このユーザーは管理者として振る舞える有効期限を短くすれば解決?
「それなら、有効期限を短くすればいいのでは?」と思うかもしれません。確かにそれは一つの対策ですが、新たな問題を生みます。
// 有効期限を5分に設定
const token = jwt.sign(payload, secret, { expiresIn: '5m' });
// 問題点
// - ユーザーは5分ごとに再ログインが必要
// - ユーザー体験が著しく悪化
// - 結局、リフレッシュトークンの仕組みが必要になるこれらの課題を解決するために、次のセクションで紹介する「ブラックリスト戦略」などの対策が必要になってくるのです。
ブラックリスト戦略でトークンを無効化する
リンドくんじゃあ、どうやってトークンを無効化するんですか?何か方法があるんですよね?
たなべそこで登場するのがブラックリスト戦略だよ。無効化したいトークンをリストに登録して、検証時にそのリストをチェックするんだ。
ステートレスの利点は少し失われるけど、セキュリティとのトレードオフなんだよね。
ブラックリストの基本概念
ブラックリスト戦略は、無効化したいトークンのIDや識別情報をデータベースやキャッシュに保存し、トークン検証時にそのリストをチェックする方法です。
基本的な流れ
- トークン発行時に一意のID(JTI: JWT ID)を付与
- ログアウトや権限変更時に、そのトークンIDをブラックリストに追加
- トークン検証時に、ブラックリストに含まれていないかチェック
Redisを使った実装例
高速なアクセスが必要なブラックリストには、インメモリデータベースのRedisがよく使われます。
const redis = require('redis');
const { v4: uuidv4 } = require('uuid');
const jwt = require('jsonwebtoken');
const redisClient = redis.createClient();
// JTI(JWT ID)付きトークンの生成
function generateToken(user) {
const jti = uuidv4(); // 一意のIDを生成
const token = jwt.sign(
{
jti: jti,
userId: user.id,
email: user.email,
role: user.role
},
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
return token;
}
// ログアウト処理 トークンをブラックリストに追加
async function logout(token) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const jti = decoded.jti;
// トークンの残り有効期間を計算
const expiresIn = decoded.exp - Math.floor(Date.now() / 1000);
// Redisにブラックリストとして登録(有効期限と同じTTLを設定)
await redisClient.setEx(
`blacklist:${jti}`,
expiresIn,
'revoked'
);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
}
// トークン検証ミドルウェア(ブラックリストチェック付き)
const verifyTokenWithBlacklist = async (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ message: 'トークンが提供されていません' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// ブラックリストに含まれていないかチェック
const isBlacklisted = await redisClient.get(`blacklist:${decoded.jti}`);
if (isBlacklisted) {
return res.status(403).json({ message: 'このトークンは無効化されています' });
}
req.user = decoded;
next();
} catch (error) {
return res.status(403).json({ message: '無効なトークンです' });
}
};ブラックリスト戦略の長所と短所
長所
- 確実にトークンを無効化できる
- ログアウトや権限変更に即座に対応できる
- セキュリティインシデント発生時に迅速に対処可能
短所
- ステートレスの利点が失われる
- データベースやキャッシュへのアクセスが必要
- システムの複雑性が増す
- パフォーマンスへの影響がある
これはセキュリティと利便性のトレードオフです。システムの要件に応じて、適切なバランスを見極める必要があります。
リフレッシュトークンとアクセストークンの組み合わせ
リンドくんブラックリストを使うと毎回チェックが必要になりますよね?もっと効率的な方法はないんですか?
たなべいい着眼点だね!
実はリフレッシュトークンという仕組みを使うことで、セキュリティと利便性のバランスを取ることができるんだ。
2種類のトークンを使い分ける戦略
リフレッシュトークン戦略では、以下の2種類のトークンを使い分けます。
- アクセストークン - 短い有効期限(5〜15分)で、API呼び出しに使用
- リフレッシュトークン - 長い有効期限(数日〜数週間)で、アクセストークンの再発行に使用
この戦略のメリット
アクセストークンの有効期限が短いため、以下のメリットがあります。
- 万が一トークンが漏洩しても、被害を最小限に抑えられる
- ブラックリストのチェックはリフレッシュトークンに対してのみ行えば良い
- 通常のAPI呼び出しではブラックリストチェック不要で高速
実装例
const crypto = require('crypto');
// トークンペアの生成
function generateTokenPair(user) {
// 短命のアクセストークン
const accessToken = jwt.sign(
{
userId: user.id,
email: user.email,
role: user.role,
type: 'access'
},
process.env.JWT_SECRET,
{ expiresIn: '15m' } // 15分
);
// 長命のリフレッシュトークン
const refreshTokenId = crypto.randomUUID();
const refreshToken = jwt.sign(
{
userId: user.id,
jti: refreshTokenId,
type: 'refresh'
},
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' } // 7日間
);
// リフレッシュトークンをデータベースに保存
saveRefreshToken(user.id, refreshTokenId, refreshToken);
return { accessToken, refreshToken };
}
// リフレッシュトークンをデータベースに保存
async function saveRefreshToken(userId, tokenId, token) {
await db.query(
`INSERT INTO refresh_tokens (id, user_id, token, expires_at)
VALUES ($1, $2, $3, NOW() + INTERVAL '7 days')`,
[tokenId, userId, token]
);
}
// アクセストークンの再発行エンドポイント
app.post('/api/refresh', async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ message: 'リフレッシュトークンが必要です' });
}
try {
// リフレッシュトークンを検証
const decoded = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);
// データベースで有効性を確認
const result = await db.query(
`SELECT * FROM refresh_tokens
WHERE id = $1 AND user_id = $2 AND revoked = false AND expires_at > NOW()`,
[decoded.jti, decoded.userId]
);
if (result.rows.length === 0) {
return res.status(403).json({ message: '無効なリフレッシュトークンです' });
}
// ユーザー情報を取得
const user = await User.findById(decoded.userId);
// 新しいアクセストークンを発行
const newAccessToken = jwt.sign(
{
userId: user.id,
email: user.email,
role: user.role,
type: 'access'
},
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken: newAccessToken });
} catch (error) {
return res.status(403).json({ message: 'トークンの更新に失敗しました' });
}
});
// ログアウト処理(リフレッシュトークンを無効化)
app.post('/api/logout', async (req, res) => {
const { refreshToken } = req.body;
try {
const decoded = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);
// データベースでリフレッシュトークンを無効化
await db.query(
`UPDATE refresh_tokens SET revoked = true WHERE id = $1`,
[decoded.jti]
);
res.json({ message: 'ログアウトしました' });
} catch (error) {
res.status(400).json({ message: 'ログアウトに失敗しました' });
}
});クライアント側の実装
// トークンペアを管理するクラス
class TokenManager {
constructor() {
this.accessToken = localStorage.getItem('accessToken');
this.refreshToken = localStorage.getItem('refreshToken');
}
// トークンを保存
saveTokens(accessToken, refreshToken) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
}
// アクセストークンを更新
async refreshAccessToken() {
const response = await fetch('/api/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: this.refreshToken })
});
if (!response.ok) {
// リフレッシュトークンも無効な場合は再ログインが必要
this.clearTokens();
window.location.href = '/login';
return null;
}
const data = await response.json();
this.accessToken = data.accessToken;
localStorage.setItem('accessToken', data.accessToken);
return data.accessToken;
}
// API呼び出し(自動的にトークンをリフレッシュ)
async fetchWithAuth(url, options = {}) {
// 最初はアクセストークンで試行
let response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${this.accessToken}`
}
});
// 401エラーならトークンをリフレッシュして再試行
if (response.status === 401) {
const newToken = await this.refreshAccessToken();
if (newToken) {
response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${newToken}`
}
});
}
}
return response;
}
// ログアウト
async logout() {
await fetch('/api/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: this.refreshToken })
});
this.clearTokens();
}
clearTokens() {
this.accessToken = null;
this.refreshToken = null;
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
}
}
// 使用例
const tokenManager = new TokenManager();
async function getUserProfile() {
const response = await tokenManager.fetchWithAuth('/api/profile');
return await response.json();
}この戦略により、セキュリティと利便性の両立が可能になります。アクセストークンは短命なので、ブラックリストチェックなしでも比較的安全です。
一方、長命のリフレッシュトークンはデータベースで管理することで、確実に無効化できます。
セキュリティのベストプラクティス
リンドくんJWTを使うときに、他にも気をつけることってありますか?
たなべもちろん!ブラックリストやリフレッシュトークンだけでは不十分なんだ。総合的なセキュリティ対策が必要だよ。
JWTを安全に使うためには、複数のセキュリティ対策を組み合わせることが重要です。
1. 強力な秘密鍵の使用
// 悪い例
const secret = 'mysecret'; // 短すぎる、推測されやすい
// 良い例
const secret = process.env.JWT_SECRET; // 環境変数から取得
// 例: "5f8e9d2c1a3b4e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0t1u2v3w4x5y6z"秘密鍵は以下の条件を満たすべきです。
- 最低256ビット(32文字以上)の長さ
- ランダムな文字列を使用
- 環境変数やシークレット管理サービスで管理
- 定期的にローテーションする
2. HTTPSの必須化
JWTはBase64エンコードされているだけなので、平文通信では簡単に盗聴されます。
// サーバー側でHTTPSを強制
app.use((req, res, next) => {
if (req.header('x-forwarded-proto') !== 'https' && process.env.NODE_ENV === 'production') {
res.redirect(`https://${req.header('host')}${req.url}`);
} else {
next();
}
});3. 適切な有効期限の設定
// 用途に応じて適切な期限を設定
const tokens = {
// 短命 API呼び出し用
access: { expiresIn: '15m' },
// 中程度 メール認証用
emailVerification: { expiresIn: '24h' },
// 長命 リフレッシュ用(ただしデータベース管理が必須)
refresh: { expiresIn: '7d' }
};4. センシティブ情報をペイロードに含めない
// 悪い例
const token = jwt.sign({
userId: user.id,
password: user.password, // NG!
creditCard: user.creditCard // NG!
}, secret);
// 良い例
const token = jwt.sign({
userId: user.id,
email: user.email,
role: user.role
}, secret);ペイロードは誰でも簡単にデコードできることを忘れないでください。
5. アルゴリズムの明示的な指定
// 悪い例 アルゴリズムを指定しない
jwt.verify(token, secret);
// 良い例 使用するアルゴリズムを明示
jwt.verify(token, secret, { algorithms: ['HS256'] });これにより、「noneアルゴリズム攻撃」などを防げます。
6. トークンの保存場所
クライアント側でのトークン保存には、以下の選択肢があります。
localStorage
- 利点 シンプル、ページ遷移で維持される
- 欠点 XSS攻撃に脆弱
sessionStorage
- 利点 タブを閉じると消える
- 欠点 XSS攻撃に脆弱
HttpOnly Cookie
- 利点 JavaScriptからアクセスできないのでXSS攻撃に強い
- 欠点 CSRF攻撃への対策が必要
7. レート制限の実装
const rateLimit = require('express-rate-limit');
// ログインエンドポイントにレート制限を設定
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15分
max: 5, // 最大5回まで
message: 'ログイン試行が多すぎます。しばらく待ってから再試行してください。'
});
app.post('/api/login', loginLimiter, async (req, res) => {
// ログイン処理...
});8. ロギングと監視
// トークン検証の失敗をログに記録
const verifyToken = async (req, res, next) => {
const token = req.headers['authorization']?.split(' ')[1];
try {
const decoded = jwt.verify(token, secret);
req.user = decoded;
next();
} catch (error) {
// 失敗をログに記録
logger.warn('Token verification failed', {
ip: req.ip,
userAgent: req.get('user-agent'),
error: error.message
});
return res.status(403).json({ message: '無効なトークンです' });
}
};これらの対策を組み合わせることで、JWTを使った認証システムのセキュリティを大幅に向上させることができます。
まとめ
リンドくんJWTって奥が深いですね...でも、ちゃんと理解できた気がします!
たなべよく頑張ったね!
JWTは便利だけど、正しく理解して適切に実装することが何より重要なんだ。今日学んだことを実践してみてね。
JWT(JSON Web Token)は、現代のWeb開発において非常に重要な技術ですが、その特性を正しく理解せずに使うと深刻なセキュリティ問題を引き起こす可能性があります。
この記事で解説した重要なポイントをまとめます。
- 3つの部分(ヘッダー、ペイロード、署名)から構成される
- ステートレスな認証が可能で、スケーラビリティに優れる
- ペイロードは暗号化されていないため、センシティブ情報は含めない
- JWTは一度発行すると期限切れまで無効化できない
- ブラックリスト戦略でトークンを無効化可能
- リフレッシュトークンと組み合わせることで、セキュリティと利便性を両立
- 強力な秘密鍵を使用し、定期的にローテーション
- HTTPSを必須化
- 適切な有効期限を設定(短命なアクセストークンと長命なリフレッシュトークン)
- レート制限やロギングで異常な動作を検知
JWTは単なる「ログインの仕組み」以上のものです。それはWebセキュリティの根幹を成す技術であり、エンジニアとして成長していく上で避けて通れない重要なテーマです。
セキュリティは一度学んで終わりではなく、常に最新の脅威と対策を学び続ける必要がある分野です。
ぜひ継続的に学習を続けて、安全なWebアプリケーションを開発できるエンジニアを目指してください!





