JWT 認証ベストプラクティス:アルゴリズム・保存・失効
乱用されがちな JWT
JWT は「ログインが必要」のデフォルト選択になり下がったが、コードレビューで目にする誤りは決まっている。ユーザーのパスワードを payload に詰め込む、脆弱な alg: none のライブラリ版を使う、token を localStorage に入れてなぜ XSS で盗まれるか不思議がる、期限のない token を発行して失効できない、など。
JWT 自体に悪いところはない。「それが万能の session 代替だ」と勘違いするのが問題だ。本番で本当に痛い点をここにまとめる。
1. JWT とは何か
JWT は . でつないだ三部分からなる:header.payload.signature。
- header:アルゴリズムと型。例
{"alg":"HS256","typ":"JWT"} - payload:クレーム。例
{"sub":"123","name":"Ada","exp":1700000000} - signature:前半二つを秘密鍵で署名し、改ざんを防ぐ
重要:payload は Base64Url エンコードされているだけで、誰でもデコードして読める。暗号化ではない。パスワードやマイナンバー、token そのものを入れてはいけない。
echo 'eyJzdWIiOiIxMjMifQ' | base64 -d
# → {"sub":"123"}
2. 三つのよくある誤り
誤り1:JWT を session として使う
従来の session は状態をサーバー側に持ち、ユーザーの失効はサーバー側で session を消すだけだ。JWT は既定でステートレス——サーバーは保持しないので「失効」が難しくなる。頻繁な失効や強制ログアウトが必要なら、JWT は session より簡単ではなく、むしろ厄介だ(第6節参照)。
誤り2:payload への機密データ
payload は読めるので、パスワードや平文メール、内部権限位を入れるのは漏洩リスクだ。必要最小・非機密の識別子だけを入れる:sub(ユーザーID)、role、exp。
誤り3:期限のない token
exp なし、あるいは数年後の exp は永久通行証だ。漏洩すれば攻撃者はずっと使える。
3. アルゴリズムの安全:攻撃はここから
none アルゴリズム攻撃
古いライブラリのなかには alg: none のとき署名検証をスキップしたものがあった。攻撃者は header を {"alg":"none"} に変え、signature を空にして任意の payload を偽造する。
防御:サーバーは許可アルゴリズムを白リストで明示し、none を拒否する。
jwt.verify(token, secret, { algorithms: ["HS256"] });
クライアントにアルゴリズムを決めさせてはならない。
RS256 / HS256 混同攻撃
古典的な「アルゴリズム混同」:サーバーは RSA 公開鍵で検証(RS256 期待)しているが、攻撃者は header を HS256 に変え、公開鍵を HMAC の秘密鍵として署名する。公開鍵はたいてい公開されているので、攻撃者は正当な token を偽造できる。
防御:
- 検証時にアルゴリズムと鍵の種類を固定する:RS256 は公開鍵で、HS256 は対称鍵で。
- RSA 公開鍵を HMAC 秘密鍵に使ってはならない。
- 成熟ライブラリ(jsonwebtoken、jose)を使い、
algorithmsを明示する。
jwt.verify(token, publicKey, { algorithms: ["RS256"] });
鍵管理
- 対称(HS256)の secret は長く、乱数で、リポジトリ外(環境変数/シークレットマネージャ)に。
- 非対称(RS256/ES256)の秘密鍵は署名サーバーのみ、公開鍵は配布可。
- 鍵ローテーションを支援(
kidヘッダで現鍵を識別)。
4. 有効期限とリフレッシュ:access + refresh 設計
ベストプラクティスは token を二種に分ける:
- access token:短期(例 15 分)、API 呼び出しに使用、漏洩窓は小さい。
- refresh token:長期(例 7 日)、新しい access token を発行するためだけ、より安全に保存。
const accessToken = jwt.sign({ sub: user.id, role }, accessSecret, {
algorithm: "RS256",
expiresIn: "15m",
});
const refreshToken = jwt.sign({ sub: user.id, jti: refreshId }, refreshSecret, {
algorithm: "RS256",
expiresIn: "7d",
});
リフレッシュエンドポイントは refresh token のみ受け付け、**毎回新しい refresh token を発行(ローテーション)**し、古いものを無効化する。こうすれば refresh token が漏れても一度使えば置き換わる。
5. 保存の安全:localStorage と Cookie
これは XSS と CSRF のトレードオフだ。
localStorage のリスク
localStorage の token は任意の JS から読める。XSS が一つあれば(脆弱なサードパーティスクリプトでも)、token は攻撃者サーバーへ持ち出される。localStorage は HttpOnly にできず、「HTTP 専用」と mark できない。
Cookie(HttpOnly + Secure + SameSite)のほうが安全
access token を Cookie に入れる:
HttpOnly:JS から読めず、XSS 窃取を防ぐ。Secure:HTTPS のみ。SameSite=Strict/Lax:大半の CSRF を防ぐ。
代償は CSRF 対応(第7節)。大半の Web アプリでは localStorage より安全な既定だ。
res.setHeader("Set-Cookie", [
`access=${accessToken}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=900`,
]);
折衷案
- SPA + 同一オリジンbackend:access を HttpOnly Cookie に、フロントは fetch で自動付与。
- モバイル/サードパーティ呼び出し:Authorization ヘッダ + 短期 access token、refresh は安全な保存へ。
6. 失効とブラックリスト:jti
短期 access + ローテーション refresh で大半のリスクは抑えられる。しかし「今すぐこのユーザーを追い出す」には?各 token に一意の jti(JWT ID)を振り、「失効リスト」(Redis ブラックリスト、TTL は token の残り寿命)を管理する:
await redis.set(`blacklist:${jti}`, "1", "EX", remainingSeconds);
if (await redis.get(`blacklist:${jti}`)) throw new Error("revoked");
サーバーに全 token を保存する必要はなく、「失効済」だけを保存すればよい。コストは極小。ブラックリストがカバーするのは「能動的に失効させたい」場面(BAN、パスワード変更)のみ。通常の期限切れは引き続き exp 任せ。
7. CSRF 対策と SameSite
Cookie を使うなら CSRF を考慮する。攻撃者がユーザーのブラウザを誘導して自ドメインへリクエストを送らせ、ブラウザが Cookie を自動付与して、ユーザーになりすまして操作させる。
- SameSite=Lax/Strict:現代ブラウザは既定で大半のクロスサイト Cookie 付与を防ぐ。優先は
SameSite=Lax。 - CSRF Token:機微な書き込みには、サーバー発行のランダム token(double-submit)を要求。
- 機微操作の再認証:パスワード変更や送金などは、Cookie に加え第二段階認証を。
app.post("/api/transfer", requireReauth, handler);
8. 実戦:Node/Express ミドルウェア
最小だが安全なガードミドルウェア:
import jwt from "jsonwebtoken";
export function authGuard(publicKey: string) {
return (req, res, next) => {
const token = req.cookies?.access; // HttpOnly Cookie
if (!token) return res.status(401).json({ error: "no token" });
let payload;
try {
payload = jwt.verify(token, publicKey, { algorithms: ["RS256"] });
} catch {
return res.status(401).json({ error: "invalid token" });
}
if (blacklist.has(payload.jti)) return res.status(401).json({ error: "revoked" });
req.user = { id: payload.sub, role: payload.role };
next();
};
}
付随のリフレッシュエンドポイント:
app.post("/api/refresh", async (req, res) => {
const rt = req.cookies?.refresh;
if (!rt) return res.status(401).json({ error: "no refresh" });
const payload = jwt.verify(rt, refreshPublicKey, { algorithms: ["RS256"] });
if (blacklist.has(payload.jti)) return res.status(401).json({ error: "revoked" });
blacklist.add(payload.jti); // 古い refresh を失効
const newAccess = issueAccess(payload.sub);
const newRefresh = issueRefresh(payload.sub); // 新 jti
res.setHeader("Set-Cookie", [accessCookie(newAccess), refreshCookie(newRefresh)]);
res.json({ ok: true });
});
よくある質問
Q1:JWT と session どちらが良い?
要件次第。サーバー側集中管理、頻繁な追い出し、大きなセッション状態が必要なら session。ステートレス、クロスサービス、モバイル友好なら JWT。人気だからといって無思考に JWT を選ばない。
Q2:access token の有効期限は?
Web アプリなら 5〜15 分が妥当、リフレッシュローテーションと併用。長すぎは漏洩窓が大きく、短すぎは頻繁なリフレッシュで体験を損なう。
Q3:なぜライブラリが「invalid algorithm」と言う?
たいていサーバーは RS256 期待だが HS256 を受け取った、あるいはその逆——アルゴリズムが固定されていない。algorithms: [...] を明示し、RS256 は公開鍵で検証すること。
Q4:フロントどうやってユーザー情報を得る?
機微データのため JWT をデコードしてはいけない(payload は読めるが信頼できず、古い可能性も)。/api/me で権威的なユーザー情報を返し、フロントはユーザーIDなどの非機密識別子だけを保持する。
Q5:JWT は暗号化できる?
できる。JWE(JSON Web Encryption)で payload を暗号化可能。しかし大半のケースは署名(改ざん防止)だけで足りる。暗号化は複雑さを増す。機微データは入れないほうが、暗号化するより楽だ。
おすすめツール
token をデバッグして中身を確認するとき、ToolsKu の以下のツールが役立つ:
- JWT デコード — token を貼り付けて header/payload を表示(署名検証なし、閲覧のみ)
- JWT 生成器 — 自分で header/payload/secret を埋めてテスト token を生成
- HMAC — HS256 署名が一致するか手検証
JWT は session の代替ではなく、別のトレードオフだ。「ステートレス・分散可能」を得る代わりに「失効の難しさ・payload の可読性」を失う。得たものと失ったものを自覚してこそ、本当に使いこなしたと言える。
ブラウザローカルツールを無料で試す →