はじめに:JWT の落とし穴
JWT(JSON Web Token)は API 認証の事実上の標準として広く使われていますが、正しく使わないと深刻なセキュリティ問題に直結します。「ライブラリに任せれば安全」と思いがちですが、JWT に固有の罠は意外と多く、一度実装した認証基盤がそのまま侵入経路になる事例は珍しくありません。
この記事では、JWT を扱うときに知っておきたい 典型的な問題 を、脆弱なコードと修正後のコードを並べて解説し、最後に本番投入前のチェックリストにまとめます。仕様の根拠は IETF の RFC 7519(JWT) と RFC 8725(JWT Best Current Practices) を一次情報として参照しています。
JWT の構造とクレーム
JWT は Header.Payload.Signature の3パート構成で、それぞれが Base64URL でエンコードされています。
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyMSJ9.SflKxwRJSMeKKF2QT4...
└── Header ──┘└── Payload ──┘└─── Signature ───┘Header と Payload は単なる Base64URL なので誰でも読めます(後述)。Signature だけが、発行者の鍵を知っている人にしか作れません。Payload には「クレーム」と呼ばれる項目が入り、RFC 7519 で意味が決まっている標準クレームは検証側で必ずチェックします。
| クレーム | 意味 | 検証側ですべきこと |
|---|---|---|
iss | 発行者(Issuer) | 自分が信頼する発行者と一致するか |
aud | 想定利用者(Audience) | このトークンが「自分のサービス向け」か |
exp | 失効時刻(Expiration) | 現在時刻を過ぎていないか(必須) |
nbf | 有効開始時刻(Not Before) | まだ有効になっていないトークンを弾く |
iat | 発行時刻(Issued At) | 古すぎるトークンの判定に利用 |
sub / jti | 主体(ユーザーID)/ トークン一意ID | jti は取り消しや再利用検知の鍵になる |
手元のトークンの中身は JWT Decoder で分解・確認できます(すべてブラウザ内で処理され、サーバーには送信されません)。
そもそも JWT を使うべきか:セッションとの比較
JWT は万能ではありません。「ステートレスにしたい」「複数サービス間でユーザー情報を持ち回りたい」という要件がないなら、サーバー側セッション(Cookie + セッションストア)の方が安全で簡単なことが多いです。
| 観点 | サーバーセッション | JWT(ステートレス) |
|---|---|---|
| 状態の保持 | サーバー側に保存 | トークン自体が状態を持つ |
| 即時失効 | 得意(ストアから消すだけ) | 苦手(後述) |
| 水平スケール | 共有ストアが要る | 得意(鍵だけ共有) |
| 情報の秘匿 | サーバー側なので安全 | Payload は誰でも読める |
判断軸の詳細は セッション認証と JWT の違い で整理しています。ここからは「JWT を使うと決めた場合」の正しい実装を見ていきます。
問題1: alg=none 攻撃
JWT の Header には alg(アルゴリズム)クレームがあり、検証側のライブラリはこの値を見てアルゴリズムを切り替えます。ここで 攻撃者が alg を none に書き換えると、署名なしで通ってしまうライブラリがありました。
{"alg":"none","typ":"JWT"}.{"sub":"admin"}. ← 署名は空欄2015年に多くのライブラリが影響を受けた古典的な脆弱性です。根本原因は「Header の alg を信頼してしまう」こと。検証側で受け入れるアルゴリズムをハードコードで限定すれば防げます。
// 脆弱: トークン側の alg を信用してしまう
jwt.verify(token, secret);
// 修正: 受け入れる alg を明示(none は決して許可しない)
jwt.verify(token, secret, { algorithms: ['HS256'] });問題2: アルゴリズム混同(RS256 → HS256)
RS256(RSA 公開鍵署名)で運用しているサービスに対し、攻撃者が alg を HS256(HMAC)に書き換え、公開鍵をそのまま HMAC の秘密鍵として使う攻撃です。多くのライブラリが「RS256 用に渡された公開鍵」を「HS256 の検証鍵」として受け入れてしまうのが問題でした。公開鍵は誰でも入手できるため、攻撃者は任意のトークンを偽造できます。
// 脆弱: 公開鍵を渡すが alg を制限していない
// → 攻撃者が alg を HS256 に変え、公開鍵を HMAC 鍵として悪用できる
jwt.verify(token, publicKey);
// 修正: 「このトークンは RS256 でなければならない」と固定する
jwt.verify(token, publicKey, { algorithms: ['RS256'] });RS256 で verify に渡す鍵は「公開鍵」、署名に使うのは「秘密鍵」です。HMAC(共通鍵)系と公開鍵系を混在させないことが原則です。HMAC 署名そのものの挙動は HMAC Generator で確認できます。
問題3: 弱い HMAC 秘密鍵
HMAC(HS256/384/512)を使う場合、秘密鍵が短すぎる・推測しやすいとブルートフォースや辞書攻撃で割られます。secret や jwt-key のような語、辞書単語、十数文字程度の鍵は危険です。割れた瞬間、攻撃者は任意のユーザーになりすませるトークンを作れます。
// 脆弱: 短く推測しやすい鍵をハードコード
const secret = 'secret123';
// 修正: 暗号学的乱数で 32 バイト(256bit)以上を生成し、環境変数等で管理
// node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
const secret = process.env.JWT_SECRET;- 長さ: HS256 なら最低 32 バイト(256 bit)以上
- 由来:
crypto.randomBytes等の暗号学的乱数。Password Generator でも十分長い乱数を作れます - 禁止: ハードコード、Git へのコミット、ドキュメント掲載、複数環境での使い回し
問題4: Payload は誰でも読める
Base64URL は暗号化ではなく単なるエンコーディングです。JWT を持っている人なら誰でも Payload を復元できます。
echo "eyJzdWIiOiJ1c2VyMSIsInJvbGUiOiJhZG1pbiJ9" | base64 -d
{"sub":"user1","role":"admin"}この事実を忘れて機密情報(クレジットカード番号、パスワード、社内識別子など)を Payload に入れると、トークンを取得した第三者にすべて見えてしまいます。Payload に入れていいのは「公開しても問題ない識別子」だけだと考えてください。本当に秘匿が必要なら JWE(JSON Web Encryption)を使うか、サーバー側にデータを持つ従来型のセッションが無難です。エンコードの仕組みは Base64 ツール でも確かめられます。
問題5: 検証そのものを省略・誤用する
意外に多いのが、署名やクレームの検証を実質スキップしているケースです。
// 脆弱: decode は「中身を読むだけ」で署名検証をしない
const payload = jwt.decode(token);
if (payload.role === 'admin') { /* ... */ } // 改ざんし放題
// 修正: verify で署名・exp・iss・aud まで検証する
const payload = jwt.verify(token, key, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com',
audience: 'my-api',
});decode と verify は別物です。exp を検証しなければ期限切れトークンが通り、aud を見なければ「別サービス向けに発行されたトークン」を流用されます(トークンの取り違え攻撃)。
問題6: トークンの取り消しが難しい
JWT は「有効期限が来るまで有効」が基本設計で、サーバー側で個別に無効化するのが構造的に苦手です。ユーザーがログアウトしても、トークン自体は exp まで使い続けられます。漏洩に気づいても即座に止めにくいのは大きな弱点です。
対策の方向性:
- アクセストークンの有効期限を短く(数分〜1時間)し、長命なリフレッシュトークンで再発行する
- リフレッシュトークンはローテーション(使うたびに新しい物に差し替え、旧トークンの再利用を検知したら家系ごと無効化)する
- サーバー側に
jtiベースの取り消しリスト(denylist)を持ち、検証時に照合する(ステートレス性は犠牲になるが現実的) - 決済・パスワード変更など重要操作の前にだけ再認証を要求する
トークンをどこに保存するか:localStorage vs Cookie
ブラウザアプリで頻出の悩みが「JWT をどこに置くか」です。銀の弾丸はなく、XSS と CSRF のどちらのリスクを重く見るかのトレードオフになります。
| 観点 | localStorage | httpOnly Cookie |
|---|---|---|
| XSS で盗まれる | 盗まれる(JS から読める) | 読めない(HttpOnly) |
| CSRF | 受けにくい(手動でヘッダ付与) | 受けやすい → 要 SameSite +トークン |
| 送信方法 | 手動で Authorization ヘッダ | 自動送信 |
| 別ドメインの API | 扱いやすい | クロスサイトだと制約が増える |
実務では HttpOnly + Secure + SameSite=Lax/Strict の Cookie に入れ、別途 CSRF 対策を併用する構成が無難です(XSS による直接窃取を防げるため)。Cookie 方式を採るなら CSRF の対策 を必ずセットで実装してください。Cookie 属性の確認には HTTP Cookie Parser が使えます。いずれの方式でも、XSS を塞ぐことが最優先である点は変わりません(XSS の対策)。
本番投入前チェックリスト
- ☐ 検証時に許可する
algをホワイトリストで固定している(noneは不許可) - ☐
decodeではなくverifyを使い、署名を検証している - ☐
exp/nbf/iss/audを検証している - ☐ HMAC 鍵は 32 バイト以上の暗号学的乱数で、環境変数や Secret Manager 管理
- ☐ 公開鍵系(RS/ES)と共通鍵系(HS)を混在させていない
- ☐ Payload に機密情報を入れていない
- ☐ アクセストークンは短命+リフレッシュトークンのローテーション
- ☐ トークンは
HttpOnlyCookie 等に安全に保管し、XSS 対策を実装済み - ☐ JWT ライブラリを最新に保ち、既知脆弱性を追っている
関連ツールと記事
- JWT Decoder — トークンを分解し、
algやクレーム、署名検証を確認 - HMAC Generator — HS256 系の署名の挙動を確認
- Base64 — Header/Payload のエンコードを確認
- セッション認証と JWT の違い
- OAuth 2.0 と OpenID Connect
参考(一次情報)
- RFC 7519 — JSON Web Token (JWT)
- RFC 8725 — JSON Web Token Best Current Practices
- OWASP — JSON Web Token Cheat Sheet
おわりに
JWT そのものは仕様としてよく設計されていますが、事故のほとんどは「使う側の油断」から生まれます。実装を書くときは「誰が何を作れるか」という信頼境界を常に意識し、上のチェックリストを一つずつ潰してください。まずは手元のトークンを JWT Decoder に貼り付けて、alg と exp がどうなっているか覗いてみるところから始めるのがおすすめです。