JWTはログアウトできない|実際に発行して中身を見た

JWTはログアウトできない|実際に発行して中身を見た

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

セッションはサーバー側に状態を持つので失効でき、JWTはブラウザに中身を渡すため失効できないことを比較した図
技術的な違いは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時の被害が決定的に違う
  • 認証を通しただけで安心しない
    「そのデータが本人のものか」を必ず確認する

次に読む記事

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

わどこんのアバター わどこん

実務12年のバックエンド・インフラエンジニア。バックエンド開発からクラウド・インフラの設計・構築・運用まで担当しています。主要言語は Java・Kotlin・PHP・Python。運用の現場で拾った知見を、再現できる手順に落として残すのがこのブログのテーマです。

目次