「モダンだからJWT」で決めると、あとでログアウトが実装できないという壁に当たります。この2つの違いは状態をどこに置くかの1点だけで、そこから性質がすべて決まります。

JWTを実際に作って、中身を見る
誤解が多いところなので、実際にトークンを発行して確かめました。
=== 発行されたトークン ===
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1LTEwMDEiLCJyb2xlIjoibWVt
YmVyIiwiZXhwIjoxNzg5MDAwMDAwfQ.9zyTZDEZBpxTluhULpg8zTa4PkkKG2PDlQahCRrsS7o
=== ペイロードは誰でも読める(鍵は不要)===
{"sub":"u-1001","role":"member","exp":1789000000}
秘密鍵を持っていなくても、中身は読めます。base64で符号化されているだけで、暗号化はされていません。
ここを勘違いすると、JWTのペイロードに個人情報を入れてしまいます。メールアドレスや氏名を入れると、それはブラウザに平文で置いてあるのと同じです。sub(ユーザーID)とロールくらいに留めます。
では署名は何を守っているのか
ロールを member から admin に書き換えて、検証にかけてみました。
改ざん後のペイロード: {"sub":"u-1001","role":"admin","exp":1789000000}
元のトークンの検証 : OK 通る
改ざんトークンの検証 : NG 弾かれる
署名が守っているのは「改ざんされていないこと」だけです。「見られないこと」は守っていません。この2つを分けて理解しておくと、何を入れていいかの判断がつきます。
ログアウトできない、という問題
JWTの本質的な制約はここです。サーバーはトークンを発行したことすら覚えていません。検証は署名を計算し直すだけなので、データベースを見ません。
その結果、こうなります。
- ログアウトしても、そのトークンは有効なまま
ブラウザから消しただけで、漏れていれば期限まで使える - 権限を剥奪しても即座に効かない
管理者から降格させても、手元のトークンにはrole: adminと書いてある - 退職者のアクセスを止められない
アカウントを無効化しても、発行済みトークンは生きている
「JWTでもログアウトできます」という実装は、たいてい失効リストをサーバー側に持っています。それは、状態を持たないというJWTの利点を捨てているということです。
捨てるのが悪いわけではありません。「状態を持たない」ことに価値がない環境なら、素直にセッションにするほうが単純です。
どちらを選ぶか
| セッション | JWT | |
|---|---|---|
| 状態の置き場所 | サーバー側 | クライアント側 |
| 失効 | 即座にできる | できない(期限まで有効) |
| 権限変更の反映 | 次のリクエストから | 次回の発行まで待つ |
| 検証の負荷 | ストアへの問い合わせが必要 | 署名計算だけ |
| サーバーをまたぐ | ストアを共有すれば可能 | 共有なしで可能 |
判断の材料は「失効を即座にできる必要があるか」です。
- 管理画面・社内システム・金銭を扱うもの
セッション。退職や権限変更が即座に効かないと困る - マイクロサービス間の通信、短命なAPIアクセス
JWT。状態を共有しない利点が効く - 一般的なWebアプリ
多くの場合、セッションで十分。Redisを1つ置けば済む
「セッションはスケールしないから」という理由でJWTを選ぶ場面は、思っているより少ないです。セッションストアが性能上の問題になるのは、かなり大きな規模になってからです。
リフレッシュトークンで折り合いをつける
JWTを使いつつ失効の問題を緩和するのが、2種類のトークンを使う構成です。
- アクセストークン
JWT。有効期限を5〜15分と短くする。漏れても被害の窓が短い - リフレッシュトークン
ランダムな文字列。サーバー側に保存する。有効期限は数週間
アクセストークンが切れたら、リフレッシュトークンを使って新しいものを発行します。このときサーバー側のレコードを確認するので、そこで失効を判定できます。最大でも15分待てば、アクセスを止められることになります。
ただし、これは結局サーバー側に状態を持っている構成です。「JWTだから状態を持たない」という説明とは矛盾するので、そこは理解して選びます。

リフレッシュトークンのローテーション
リフレッシュトークンは寿命が長いので、盗まれると被害が続きます。対策として、使うたびに新しいものに置き換えて、古いものを無効化します。
この方式には副次的な効果があります。無効化したはずの古いトークンが使われたら、盗まれたと判断できます。正規の利用者は新しいトークンを持っているので、古いものを使うのは複製を持っている側だけです。
検知したら、そのユーザーのトークンをすべて失効させて、ログインし直してもらいます。
どこに保存するか
設計としてよく議論になるところです。
| 保存先 | XSSで盗まれるか | CSRFの対策 |
|---|---|---|
localStorage | 盗まれる(JSから読める) | 自動送信されないので不要 |
| HttpOnly Cookie | 読めないので盗まれにくい | 必要(自動送信される) |
HttpOnly Cookie のほうが安全側です。XSSが起きたときの被害が決定的に違います。localStorage はJavaScriptから読めるので、スクリプトを1つ注入されれば全部持っていかれます。
Set-Cookie: session=xxx; HttpOnly; Secure; SameSite=Lax; Path=/
HttpOnly
JavaScriptから読めなくするSecure
HTTPSでのみ送るSameSite=Lax
別サイトからのリクエストでは基本的に送らない。CSRF対策の土台になる
SameSite だけでCSRFが完全に防げるわけではないので、状態を変える操作にはトークンによる対策を併用します。
ログインの前後でやること
ログイン成功時にセッションIDを振り直す
見落とされやすい対策です。ログイン前に発行されていたセッションIDを、ログイン成功の瞬間に必ず新しいものに交換します。
やらないと、攻撃者が用意したセッションIDを被害者に使わせ、被害者がログインした後にそのIDで乗っ取る、という攻撃が成立します。IDを振り直せば、攻撃者が知っているIDは無効になります。
多くのフレームワークに1行の関数が用意されています。ログイン処理を自分で書いたときに抜けやすいので、確認しておく価値があります。権限が変わるタイミング(管理者への昇格など)でも同じことをします。
パスワードは保存方法より、試行回数の制限
パスワードのハッシュ化はライブラリに任せれば済みます。bcrypt や Argon2 を使う、自分でハッシュ関数を組み合わせない、それだけです。
実際に穴になりやすいのは、そこではなく試行回数の制限のほうです。
- IPアドレス単位だけの制限では足りない
「1つのパスワードを大量のアカウントに試す」攻撃は、IPを変えながら来ます - アカウント単位でも制限する
ただしロックすると、攻撃者が任意のユーザーをロックできてしまいます - 失敗を重ねたら、待ち時間を伸ばすほうが実用的です
もう1つ、ログイン失敗のメッセージで「そのユーザーが存在するか」を漏らさないようにします。「パスワードが違います」と「そのユーザーは存在しません」を出し分けると、アカウントの一覧を作られます。どちらの場合も同じ文言と、同じくらいの応答時間で返します。
認証と認可を混ぜない
最後に、設計として分けておきたい話です。
- 認証(Authentication)
あなたは誰か - 認可(Authorization)
その人に、この操作をする権限があるか
ログインしているかどうかのチェックだけで通してしまい、「他人のデータかどうか」を確認し忘れるのが、実装でいちばん多い穴です。
// 認証は通っている。しかし他人の注文も見られる
GET /api/orders/8842
// 必要なのは「その注文が、このユーザーのものか」の確認
if (order.userId !== session.userId) return 404;
IDを1つずらすだけで他人のデータが見える、というのはこの確認漏れです。URLのIDは、ユーザーが自由に書き換えられる値だと考えて扱います。
返すのは 403 ではなく 404 にする、という判断もあります。403 だと「そのIDのデータは存在する」という情報が漏れるためです。この使い分けはAPI設計の記事にも書きました。
まとめ
- 違いは状態をどこに置くかだけ
そこから失効できるかが決まる - JWTのペイロードは誰でも読める
個人情報を入れない。署名は改ざん検知であって秘匿ではない - JWTはログアウトできない
失効リストを持つなら、利点を捨てていることを自覚する - 迷ったらセッション
失効が即座に効く必要があるなら、ほぼこれ一択 - アクセストークンは短命に、リフレッシュはローテーションする
再利用を検知したら全失効 - 保存は HttpOnly Cookie
XSS時の被害が決定的に違う - 認証を通しただけで安心しない
「そのデータが本人のものか」を必ず確認する
次に読む記事



