JWTとセッションCookieの違いと使い分け

セッションCookieはサーバーに保存された状態を指す鍵で、JWTは署名付きの状態そのものをサーバーへ運びます。この1つの違いが、すべてのトレードオフを決めます。

ツールで試す: JWTデコーダー

この比較はよく「JWT対Cookie」と表現されますが、両者は同じ種類のものではありません。Cookieはリクエストのたびにデータを送る手段で、JWTはデータの形式です。本当の選択肢は、ブラウザーがランダムなIDを持ち、サーバーがそのIDの持ち主を調べるサーバー側セッションと、クライアントが署名付きのクレームを持ち、サーバーが照会なしで検証できる自己完結型トークンのどちらにするか、です。ここを押さえれば、無効化、保存場所、CSRFに関する疑問はすっきり整理できます。

サーバー側セッションの仕組み

ログインに成功すると、サーバーはセッションレコード(ユーザーID、ロール、有効期限)をメモリ、データベース、またはRedisのようなストアに作成し、推測できないIDをCookieに入れてブラウザーに送ります。ブラウザーはそのサイトへの以降のすべてのリクエストにCookieを付け、サーバーはリクエストごとにレコードを読み込みます。ID自体には何の意味もありません。32バイトの乱数をエンコードすると43文字になります。

Set-Cookie: __Host-session=p-sLYX7J3ziMKxq-dadVMuQJNSVBHM5hrw7MJPihGQ0; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=86400

__Host-プレフィックスを付けると、ブラウザーはCookieがSecureで、Path=/を持ち、Domainを指定していない場合にしか受け入れません。そのため、サブドメインから上書きされることがなくなります。

JWTの仕組み

JSON Web Token(RFC 7519)は、Base64URLでエンコードされた3つの部分をドットでつないだものです。アルゴリズムを示すヘッダー、subやexpなどのクレームを含むペイロード、そして最初の2つに対する署名です。サーバーは鍵を使って署名と有効期限を検証し、データベースに問い合わせることなくクレームを信頼します。通常はAuthorization: Bearerヘッダーで送られます。

ペイロードはエンコードされているだけで、暗号化されてはいません。トークンを持っていれば誰でも中身を読めます。JWTデコーダーに貼り付けてみれば、すぐに確認できます。ユーザー本人に見せられないような秘密情報や個人データをJWTに入れてはいけません。暗号化されたトークン(JWE、RFC 7516)もありますが、使われているJWTの大半は署名だけです。各部分が+と/ではなく-と_を使う理由は、Base64とBase64URLの違いを参照してください。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzg0MTIiLCJpYXQiOjE3NjAwMDAwMDAsImV4cCI6MTc2MDAwMDkwMH0.08BFm85KQIp34VSetcibqwBVLpHKR3IZhI_IPNeTif4

// Reading the payload needs no key at all (browser):
const part = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
const bytes = Uint8Array.from(atob(part), c => c.charCodeAt(0));
JSON.parse(new TextDecoder().decode(bytes));
// { sub: 'user_8412', iat: 1760000000, exp: 1760000900 }

上のコードはブラウザーで動くもので、ペイロードを読むのに鍵がまったく要らないことを示しています。

トレードオフの比較表

サーバー側セッションJWT
クライアントが持つものランダムで意味のないID署名付きの読めるクレーム
サーバーに必要なもの全サーバーで共有するセッションストア署名を検証する鍵だけ
ユーザーをすべての端末からログアウトさせるセッションを削除すれば即座に反映追加の状態なしではexpより前には不可能
ロールや権限の変更次のリクエストから反映新しいトークンが発行された時点で反映
一般的な送り方Cookie(自動で送信)Authorizationヘッダー、またはCookie
リクエストごとのサイズこの例では43文字のIDこの例では152~540文字

無効化、有効期限、リフレッシュトークン

ステートレスであることはJWTの最大の売りであり、同時に最大の弱点でもあります。有効なトークンは、ユーザーがログアウトしても、パスワードを変更しても、アカウントを停止されても、期限が切れるまで有効なままです。よくある対策は、アクセストークンの有効期間を短くし、数時間ではなく数分程度にしたうえで、より長く有効なリフレッシュトークンと組み合わせることです。クライアントはリフレッシュトークンを新しいアクセストークンと交換します。リフレッシュトークンはサーバー側の状態と照合されるため、これを無効化すれば新しいアクセストークンは発行されなくなります。

OAuth 2.0 Security Best Current Practice(RFC 9700)はリフレッシュトークンのローテーションを説明しています。リフレッシュのたびに新しいリフレッシュトークンを返して古いものを無効にするので、盗まれたトークンが再利用されれば検知できます。アクセストークンも即座に無効化したいとなると、トークンID(jti)の拒否リストや、ユーザーごとのトークンバージョンを持つことになり、結局はまたサーバー側の状態です。その段階まで来たら、普通のセッションのほうが簡単ではないかを検討してください。

トークンの保存場所:Cookie、localStorage、XSS

localStorageやsessionStorageに入れたものは、ページ上で動くすべてのスクリプトから読み取れます。クロスサイトスクリプティング(XSS)のバグが1つ、あるいは改ざんされたサードパーティのスクリプトが1つあるだけで、トークンをコピーされ、期限が切れるまでどこからでも使われてしまいます。

HttpOnlyを付けたCookieは、JavaScriptからは一切読めないため、XSSで盗まれることはありません。ただし、XSSは被害者が開いているページからリクエストを送ることはでき、ブラウザーはそのリクエストにCookieを付けます。つまりHttpOnlyは被害を防ぐのではなく、被害を限定するものです。どちらの方式でもXSSの防止は欠かせません。HttpOnly Cookieは、攻撃が成功した場合に攻撃者が得られるものを減らすだけです。

CSRFとSameSite

Cookieの便利さは、そのままリスクでもあります。ブラウザーは、別のWebサイトが引き起こしたリクエストにも自動でCookieを付けるからです。これがクロスサイトリクエストフォージェリ(CSRF)です。Authorizationヘッダーで送るトークンは、古典的なCSRFの影響を受けません。ブラウザーがBearerトークンを勝手に付けることはなく、自分のコードで付ける必要があるためです。

  • SameSite=Strict:別のサイトから始まったリクエストには、あなたのサイトへのリンクをたどった場合も含めて、Cookieが一切送られません。
  • SameSite=Lax:ユーザーが通常のリンクであなたのサイトに移動したとき(トップレベルのGET)には送られますが、クロスサイトのフォーム送信、fetch、埋め込みリソースでは送られません。Chromeはこの属性を指定していないCookieにLaxを適用しますが、それに頼らず明示的に指定してください。
  • SameSite=None:どこへでも送られ、Secureとの併用が必須です(併用しないとChromeは拒否します)。埋め込みウィジェットのような、本当にクロスサイトで使う場合にだけ必要です。

「同じサイト」とは登録可能ドメインが同じという意味なので、app.example.comとforum.example.comは同じサイトとして扱われます。SameSiteは乗っ取られたサブドメインからは守ってくれません。状態を変更するリクエストには、CSRFトークンを併用するか、Originヘッダーも確認してください。

トークンのサイズ

セッションIDは、ユーザーについてどれだけ情報を持っていても小さいままです。JWTはクレームを追加するたびに、また署名アルゴリズムによっても大きくなります。このガイドのために生成したトークンは、HS256でクレーム3つなら152文字、名前・メールアドレス・ロールを追加すると241文字、同じクレームをRS256と2048ビットの鍵で署名すると540文字でした。RFC 6265がブラウザーに求めているのは、名前と属性を含めて少なくとも4,096バイトのCookieに対応することだけなので、権限情報を詰め込んだJWTはCookieに収まらないことがあります。しかも、そのサイズがリクエストのたびに送られます。

JWTの安全性を損なうよくある間違い

  • algヘッダーを信用する。サーバーが受け付けるアルゴリズムは固定してください。noneを受け入れたり、トークンがRS256からHS256に切り替えて公開鍵をHMACの秘密鍵として使わせたりすると、攻撃者にトークンを偽造されます。
  • 検証せずにデコードだけする。ペイロードを読んでも何の証明にもなりません。まず、メンテナンスされているライブラリーで署名を検証してください。
  • クレームのチェックを省く。exp、nbf、iss、audを検証し、あるサービス向けに発行されたトークンが別のサービスで受け入れられないようにしてください。

JWTのベストプラクティスをまとめたRFC 8725では、これらを含むさまざまな問題が扱われています。

どんなときにどちらが向いているか

状況通常より適した方式
1つのドメインで動くサーバーレンダリングのWebアプリセッションCookie
APIと同じサイトから配信されるシングルページアプリセッションCookie、またはトークンをサーバー側に保持するBFF(backend-for-frontend)
ネイティブのモバイルアプリ短命のアクセストークンと、プラットフォームの安全なストレージに置くリフレッシュトークン
認証サービスを呼ばずにリクエストを検証するマイクロサービスRS256やES256などの非対称鍵で署名したJWTアクセストークン
サードパーティによるAPIへのアクセスOAuth 2.0のアクセストークン(JWTであることが多い)

ハイブリッド:HttpOnly Cookieの中にJWTを入れる

多くのアプリは、JWTそのものをHttpOnly; Secure; SameSite付きのCookieに入れています。スクリプトからは読めず、サーバーはセッションを照会せずに検証できます。ただしトレードオフもついてきます。Cookieは自動で送られるのでCSRF対策が再び必要になり、Cookieの4 KBの上限が適用され、無効化の難しさは他のJWTとまったく同じです。ステートレスなAPIに支えられたブラウザーアプリには堅実な設計ですが、CSRFについてはCookieとして、無効化についてはJWTとして扱うことが条件です。

よくある質問

JWTはセッションCookieより安全ですか?

いいえ、どちらかが本質的に安全というわけではありません。セッションは無効化しやすく、クライアントに何の情報も渡しません。JWTはセッションの照会が不要ですが、期限切れ前に無効化するのが困難です。形式よりも、認証情報をどこに保存し、CSRFとXSSにどう対処するかのほうが重要です。

JWTはlocalStorageに保存してもよいですか?

手軽ですが、ページ上のあらゆるスクリプトにトークンをさらすため、XSSのバグが1つあるだけで漏えいする可能性があります。SameSite属性を明示したHttpOnlyかつSecureのCookieならJavaScriptから手が届きませんが、その代わりにCSRF対策が必要になります。

JWTを無効化することはできますか?

トークン単体ではできません。署名されたトークンはexpの時刻まで有効です。それより早くアクセスを止めるには、アクセストークンの有効期間を短くする、リフレッシュトークンを無効化する、またはトークンIDを拒否リストと照合する方法がありますが、最後の方法はサーバー側の状態を増やします。

JWTのデータは暗号化されていますか?

通常はされていません。ヘッダーとペイロードはBase64URLでエンコードされたJSONで、誰でもデコードできます。改ざんを防いでいるのは署名だけです。中身を秘密にする必要があるならJWEを使うか、機密データをトークンに入れないようにしてください。
ツールで試す: JWTデコーダー