JWTセキュリティのベストプラクティス:アルゴリズム選択、有効期限、ローテーション、攻撃防御
JSON Web Tokenはいたるところで使われています — しかし、ほとんどの実装には重大なセキュリティの欠陥があります。このガイドではアルゴリズム選択、トークンの有効期限とローテーション、リフレッシュトークンパターン、主要なJWT攻撃への防御方法を解説します。
JWTとは何か、どのように機能するか?
JWTはBase64URLエンコードされた文字列で、ドットで区切られた3つの部分で構成されています:ヘッダー(アルゴリズム+タイプ)、ペイロード(クレーム)、署名。
アルゴリズム選択:HS256 vs RS256 vs ES256
最も一般的なJWTセキュリティのミスは、分散システムでHS256を使用することです。RS256とES256は非対称鍵を使用します。
// Anatomy of a JWT — Header.Payload.Signature
// 1. Header (Base64URL decoded)
const header = {
alg: "RS256", // Algorithm: RS256 (asymmetric) preferred over HS256 (symmetric)
typ: "JWT"
};
// 2. Payload (Base64URL decoded) — the claims
const payload = {
iss: "https://auth.example.com", // Issuer
sub: "user_01HXYZ123", // Subject (user ID)
aud: "https://api.example.com", // Audience (target service)
exp: 1735689600, // Expiry: Unix timestamp (15 min from now)
iat: 1735688700, // Issued at
jti: "8f14e0f9-21af-4c1e-8f26-3b4a5c6d7e8f", // Unique token ID (for revocation)
roles: ["user"], // Custom claims
email: "alice@example.com"
};
// 3. Signature (server-side only)
// RS256: sign(base64url(header) + '.' + base64url(payload), PRIVATE_KEY)
// Verification: verify(token, PUBLIC_KEY) — other services only need the public key
// --- Generating a JWT with jsonwebtoken (Node.js) ---
import jwt from 'jsonwebtoken';
import { readFileSync } from 'fs';
import { randomUUID } from 'crypto';
const privateKey = readFileSync('./private.pem'); // RSA private key
const publicKey = readFileSync('./public.pem'); // RSA public key (shared with all services)
function issueAccessToken(userId: string, roles: string[]): string {
return jwt.sign(
{
sub: userId,
aud: 'https://api.example.com',
roles,
jti: randomUUID(),
},
privateKey,
{
algorithm: 'RS256',
issuer: 'https://auth.example.com',
expiresIn: '15m', // Short-lived access token
}
);
}
function verifyAccessToken(token: string) {
return jwt.verify(token, publicKey, {
algorithms: ['RS256'], // CRITICAL: whitelist algorithms — never allow 'none'
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
});
}トークンの有効期限とローテーション戦略
短命のアクセストークン(15分)と長命のリフレッシュトークン(7〜30日)のペアが業界標準です。
// Refresh Token Pattern — Stateless Access + Stateful Refresh
import jwt from 'jsonwebtoken';
import { randomBytes } from 'crypto';
// ---- Token issuance ----
async function issueTokenPair(userId: string) {
const accessToken = jwt.sign(
{ sub: userId, jti: randomUUID() },
process.env.JWT_PRIVATE_KEY!,
{ algorithm: 'RS256', expiresIn: '15m' }
);
// Refresh token: random bytes stored in DB — NOT a JWT
const refreshToken = randomBytes(64).toString('hex');
const refreshExpiry = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
// Store hashed refresh token in DB (never store plaintext)
await db.refreshTokens.create({
tokenHash: sha256(refreshToken),
userId,
expiresAt: refreshExpiry,
createdAt: new Date(),
});
return { accessToken, refreshToken, refreshExpiry };
}
// ---- Token refresh endpoint ----
app.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.cookies; // httpOnly cookie
if (!refreshToken) return res.status(401).json({ error: 'No refresh token' });
const stored = await db.refreshTokens.findOne({
tokenHash: sha256(refreshToken),
expiresAt: { >: new Date() }, // Not expired
revokedAt: null, // Not revoked
});
if (!stored) return res.status(401).json({ error: 'Invalid or expired refresh token' });
// Rotate: invalidate old, issue new pair (refresh token rotation)
await db.refreshTokens.update(
{ id: stored.id },
{ revokedAt: new Date() } // Revoke old refresh token
);
const newPair = await issueTokenPair(stored.userId);
// Set new refresh token as httpOnly cookie
res.cookie('refreshToken', newPair.refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
expires: newPair.refreshExpiry,
path: '/auth/refresh', // Scope cookie to refresh endpoint only
});
return res.json({ accessToken: newPair.accessToken });
});
// ---- Logout: revoke refresh token ----
app.post('/auth/logout', async (req, res) => {
const { refreshToken } = req.cookies;
if (refreshToken) {
await db.refreshTokens.update(
{ tokenHash: sha256(refreshToken) },
{ revokedAt: new Date() }
);
}
res.clearCookie('refreshToken', { path: '/auth/refresh' });
return res.json({ success: true });
});一般的なJWT攻撃と防御
最も危険なJWT脆弱性:アルゴリズム混乱、RS256→HS256混乱、弱い秘密鍵、exp検証の欠如。
// JWT Attack Defenses — Code Patterns
// ---- ATTACK 1: Algorithm confusion ("none" bypass) ----
// VULNERABLE: never use verify() without specifying allowed algorithms
const VULNERABLE = jwt.verify(token, secret); // attacker can set alg: "none"
// SAFE: always whitelist algorithms
const SAFE = jwt.verify(token, publicKey, {
algorithms: ['RS256'], // Never include 'none', 'HS256' if you're using RS256
});
// ---- ATTACK 2: RS256 > HS256 confusion ----
// Attacker changes alg from RS256 to HS256 and signs with the PUBLIC key as the secret
// Defense: always specify algorithm on verify — never infer from the token header
// VULNERABLE:
const header = JSON.parse(atob(token.split('.')[0]));
jwt.verify(token, publicKey, { algorithms: [header.alg] }); // attacker controls this!
// SAFE:
jwt.verify(token, publicKey, { algorithms: ['RS256'] }); // hardcoded
// ---- ATTACK 3: Weak HS256 secrets (brute-force) ----
// VULNERABLE: short or predictable secrets
const BAD_SECRET = 'secret';
const BAD_SECRET2 = 'mysupersecretkey';
// SAFE: 256-bit cryptographically random secret
import { randomBytes } from 'crypto';
const GOOD_SECRET = randomBytes(32).toString('hex'); // 256-bit
// Store in environment variable, rotate annually
// ---- ATTACK 4: Missing expiry validation ----
// Some older libraries skip exp check by default
// SAFE: always validate exp (jsonwebtoken does this by default)
try {
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
clockTolerance: 30, // Allow 30 seconds clock skew max
ignoreExpiration: false, // NEVER set this to true in production
});
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid token' });
}
// ---- Token blacklist (for early revocation via jti) ----
import { createClient } from 'redis';
const redis = createClient();
async function revokeToken(jti: string, exp: number) {
const ttl = exp - Math.floor(Date.now() / 1000); // seconds until natural expiry
if (ttl > 0) {
await redis.set(`jwt:revoked:${jti}`, '1', { EX: ttl });
}
}
async function isTokenRevoked(jti: string): Promise<boolean> {
return (await redis.get(`jwt:revoked:${jti}`)) !== null;
}
// Middleware: check blacklist
app.use(async (req, res, next) => {
const decoded = jwt.verify(req.headers.authorization?.split(' ')[1] ?? '', publicKey, {
algorithms: ['RS256'],
});
if (await isTokenRevoked((decoded as jwt.JwtPayload).jti ?? '')) {
return res.status(401).json({ error: 'Token revoked' });
}
next();
});セキュアなトークン保存
アクセストークン:メモリに保存。リフレッシュトークン:httpOnly、Secure、SameSite=StrictのCookieに保存。
よくある質問
JWTとopaque セッショントークンのどちらを使うべきか?
サーバーサイドレンダリングアプリにはopaqueセッショントークンを使用。マイクロサービスやAPIにはJWTを使用。
有効期限前にJWTを失効させるには?
解決策:短い有効期限(15分)、Redisでのトークンブラックリスト、ユーザーレコードのトークンバージョン。
すべてのJWTに含まれるべきクレームは?
必須:iss、sub、aud、exp、iat、jti。受信サービスでissとaudを必ず検証してください。
JWTの秘密鍵と秘密鍵の長さは?
HS256の秘密鍵:最低256ビット。RS256:最低2048ビットのRSA鍵。ES256:256ビットの楕円曲線鍵。