JWTとセッションCookieの違いと使い分け
セッションCookieはサーバーに保存された状態を指す鍵で、JWTは署名付きの状態そのものをサーバーへ運びます。この1つの違いが、すべてのトレードオフを決めます。
この比較はよく「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として扱うことが条件です。