この記事のポイント
- ブラウザに渡すアクセストークンを
localStorageに置く実装は、XSS が一度通っただけでトークンを根こそぎ持っていかれやすいので、現在は避ける流れになっています - 数年前まで広がっていた「SPA だから JWT を
Authorization: Bearerで送る」設計は、サードパーティ Cookie 規制や BFF パターンの普及で前提が変わってきました - 2026 年現在の落としどころは、ブラウザとサーバの境界は HttpOnly Cookie、サーバ間は JWT という役割分担におさまりつつあるように見えます
- とはいえ JWT 自体が悪いわけではなく、「どこに置くか」「いつ失効させるか」「どの層で使うか」を分けて考えると整理しやすくなります
- この記事では結論の手前にある「なぜそうなったか」を、Cookie セッションの歴史と JWT 流行の動機、そして揺り戻しの背景まで含めて掘り下げてみます
想定読者は、SPA やモバイルアプリの認証を実装したことがあり、「localStorage に JWT を置くな」という主張は聞くものの、その背景まではうまく説明できない、という方です。特定のフレームワークやバージョンには依存しない、概念整理が中心の記事として読んでいただければと思います。
なぜこのテーマが繰り返し蒸し返されるのか
「localStorage に JWT を置くな」という主張は、もう何年も前から定期的に議論されてきました。それでもこのテーマが消えないのは、技術的な正解が一つに定まらないからだと感じています。保管場所の選択は、XSS と CSRF という性質の違う攻撃の、どちらをより重く見るかというトレードオフの上に乗っています。そして、ここに唯一の答えがないからです。
加えて、前提となる環境そのものが数年単位で動いてきました。SPA が当たり前になり、サードパーティ Cookie が規制され、サーバ中心のフレームワークが復権し、と外部条件が変わるたびに「以前のベストプラクティス」が古びていきます。だからこのテーマは、定期的に「いまの前提だとどうなるか」を整理し直す必要があるのだと思います。
以下では、まず Cookie セッションという「枯れた技術」がなぜ長く使われたのか、次になぜ JWT が一気に流行したのか、そして JWT が解こうとした問題と新たに生んだ問題、最後にいまの揺り戻しの背景という順で見ていきます。
なぜ Cookie セッションは長く「枯れた技術」として使われたのか
JWT より前、Web 認証の標準は「サーバ側にセッションの実体を持ち、ブラウザにはそれを指すただの ID を Cookie で渡す」方式でした。この方式が長く支持された理由を理解しておくと、その後の JWT 流行とその反省が見えやすくなります。
サーバ側で状態を握っていることの安心感
このモデルでは、ブラウザが持っているのは session=abc123 のような不透明な ID だけです。認証情報の中身(ユーザ ID、権限、ログイン時刻など)はすべてサーバ側のセッションストアにあります。ブラウザ側に意味のあるデータがないので、Cookie を盗まれても、それ単体ではセッションを乗っ取る以上のことはできません。
そしてなにより、サーバが状態を握っているということは、いつでもその状態を消せるということです。ログアウト、パスワード変更、不審なアクセスの検知、管理者による強制ログアウト、これらをすべて「サーバ側のセッションレコードを削除する」という一手で即座に反映できます。失効が確実かつ即時に効く、というのは認証システムにとって地味ですが大きな利点です。
その代わりに抱えた「セッションストア共有問題」
一方でこのモデルには、水平スケールのときに表面化する弱点がありました。サーバを複数台に増やすと、ある利用者のセッションがどのサーバに保存されているかが問題になります。
素朴に各サーバのメモリにセッションを置くと、1 台目で発行したセッションが 2 台目のリクエストでは見つからない、という事態が起きます。これを避けるために、当時はいくつかの対処が取られていました。
- ロードバランサで同じ利用者を同じサーバに固定する「スティッキーセッション」
- セッションを Redis や Memcached などの共有ストアに集約する
- データベースにセッションテーブルを持つ
いずれも、共有ストアという単一の依存点を抱え込みます。リクエストごとにセッションストアへの参照が走るので、ストアの可用性と性能がそのままサービス全体の可用性と性能に直結する、という構造です。当時の規模感ではこれで十分回っていたものの、サービスが地理的に分散したり、サービス自体が細かく分割されたりすると、この共有ストアの取り回しが重く感じられる場面が増えていきました。この「サーバ間で状態を共有したくない」という感覚が、次の JWT 流行の伏線になっていきます。
なぜ 2015 年前後から JWT が爆発的に流行したのか
JWT(JSON Web Token、RFC 7519)の流行には、複数の動機が同じ時期に重なったという背景があります。一つの理由ではなく、いくつもの圧力が同じ方向を向いた結果として広がったように見えます。
SPA とモバイルの台頭
この時期、React や Angular による SPA、そしてスマートフォンアプリが一般化しました。これらに共通するのは、「画面を描画するクライアント」と「データを返す API」が分離している、という構造です。
従来のセッション Cookie は、ブラウザがリクエストに自動で Cookie を付ける挙動に依存していました。ところがネイティブアプリにはそもそもブラウザの Cookie 機構がありません。SPA でも、API が別ドメインにあると Cookie の扱いが一気に面倒になります。そこで「クライアントがトークンを保持し、明示的に Authorization ヘッダに乗せて送る」という、ブラウザの暗黙の挙動に頼らない方式が求められました。これが Bearer トークンとしての JWT の出番になります。
ステートレスでスケールしたいという動機
先に触れたセッションストア共有問題への、ある種のカウンターでもありました。JWT は署名付きで、トークン自体にユーザ ID や権限などの情報を埋め込めます。サーバは受け取ったトークンの署名を検証するだけで、誰がどんな権限でアクセスしているかを判断できます。
つまり、リクエストのたびに共有セッションストアを引かなくてよくなります。各サーバは署名検証の鍵さえ持っていれば独立に判断できるので、サーバを何台に増やしても状態を共有する必要がありません。「ステートレスにスケールできる」という性質は、当時のスケールアウト志向にきれいに合致していました。
マイクロサービス間の認証
同じ時期にマイクロサービス化も進みました。一つのリクエストが、ゲートウェイから複数の内部サービスへと伝播していく構成です。このとき「このリクエストは誰のものか」という認証情報を、サービスからサービスへ受け渡したいという要求が生まれます。
JWT は自己完結していて、署名を検証できれば中身を信頼できます。サービス A が受け取ったトークンをサービス B にそのまま渡せば、B は A に問い合わせることなく利用者を識別できます。この「持ち回れる認証情報」という性質は、分散したサービス間の認証伝播に都合がよく、JWT が広がる強い後押しになりました。
CORS とクロスドメインの事情
加えて、API が別オリジンにある構成では、Cookie ベースの認証は CORS の制約に引っかかりやすいという事情がありました。クロスオリジンで Cookie を送るには credentials の設定やサーバ側の許可ヘッダなど、踏むべき手続きが多く、ハマりどころも多くありました。
Authorization ヘッダにトークンを乗せる方式なら、この Cookie 特有の面倒をかなり回避できます。クロスドメイン構成で素直に動かしやすい、という実装上の手触りのよさも、JWT が選ばれた理由の一つだったと思います。
これらが重なった結果として、「ログイン時に JWT を返し、フロントで localStorage に入れ、API 呼び出しのたびに Authorization: Bearer <token> で送る」という構成が広く採用されました。当時はこの形がモダンと受け止められていた印象があります。
JWT が「解決しようとした問題」と「新たに生んだ問題」
ここがこの記事の核心だと思っています。JWT はたしかにいくつかの問題を解きましたが、同時に別の問題を持ち込みました。両者を対比して見ると、いまの揺り戻しの理由が腑に落ちやすくなります。
解決したこと
整理すると、JWT が解いたのはおおむね次の三つでした。
- セッションストア共有問題からの解放(リクエストごとにストアを引かなくてよい)
- ブラウザの Cookie 機構に依存しない、クライアント主導のトークン送信
- サービス間で持ち回れる、自己完結した認証情報
新たに生んだ問題1: 失効できない
一方で、同じ「自己完結している」という性質が、別の問題の根になりました。まず挙がるのが、失効の難しさです。
JWT は「署名が検証でき、有効期限内であれば有効」というつくりです。サーバ側に状態を持たないことが利点だったわけですが、裏を返すと、発行済みのトークンを途中で無効化する標準的な仕組みがありません。
セッション Cookie なら、サーバ側のレコードを消すだけで即座に失効しました。JWT ではそれができないので、即時失効が欲しくなった時点で、結局なんらかの状態をサーバに持ち込むことになります。
- ブラックリスト(取り消し済みの
jtiをストアに持つ) - ホワイトリスト(有効なセッションだけストアに置く)
- ユーザごとの
tokenVersionをインクリメントして既存トークンをまとめて失効させる
どれを採っても、せっかく手放したはずのサーバ側状態が戻ってきます。「ステートレスにしたい」という当初の動機と、「即時失効が欲しい」という運用要求のあいだで揺れる、というのが実務でよく見る構図です。実運用では多くの場合、アクセストークンを数分〜十数分の短命にして失効の必要性そのものを小さくし、長命なリフレッシュトークン側で状態を管理する、という折衷に落ち着きます。
新たに生んだ問題2: トークンサイズの肥大
JWT は Base64URL でエンコードされたヘッダ・ペイロード・署名がドットでつながった形式です。ユーザ属性や権限を増やすほどペイロードが膨らみます。
これを毎リクエストのヘッダに乗せると、特に API を細かく叩く SPA では、トークン分の帯域が積み上がります。不透明なセッション ID なら数十バイトで済むところが、JWT だと数百バイトから場合によっては 1KB を超えることもあり、リクエスト数が多い構成では無視できない差になります。
新たに生んだ問題3: 署名アルゴリズムにまつわる事故史
JWT の検証は実装ミスが致命傷になりやすく、歴史的にいくつもの事故が知られています。
代表的なのは alg: none の問題です。JWT のヘッダには署名アルゴリズムを示す alg フィールドがあり、ここに none を指定すると「署名なし」を意味します。これを受け入れてしまう実装だと、攻撃者が alg: none のトークンを自作して送るだけで、署名検証を素通りできてしまいました。
もう一つ有名なのが、HS256(共通鍵)と RS256(公開鍵)の取り違えです。サーバが公開鍵で RS256 を検証する想定のところに、攻撃者が「その公開鍵を共通鍵として使う HS256 トークン」を送る、という攻撃です。公開鍵は文字通り公開されているので、それを HMAC の鍵として使われると、攻撃者が正規のトークンを偽造できてしまいます。
これが成立するのは、サーバ(やライブラリ)がトークンの alg を信用して HS256 も受け入れてしまう実装に限られます。検証に使うアルゴリズムを RS256 に固定し、鍵とアルゴリズムを紐付けて検証していれば成立しません。
// alg=none 攻撃のイメージ
// ヘッダで「署名なし」を宣言し、署名部分を空にする
{ "alg": "none", "typ": "JWT" }.{ "sub": "victim", "role": "admin" }.
// HS256/RS256 混同攻撃のイメージ
// サーバは RS256 を想定して公開鍵を持っているが、
// 攻撃者はその公開鍵を「HS256 の共通鍵」として使ってトークンに署名する
これらの多くはライブラリ側の堅牢化で塞がれましたが、教訓として残るのは「サーバは受け取ったトークンの alg を信用してはいけない」という点です。自前で組むときは、検証に使うアルゴリズムをサーバ側で明示的に固定する、という設計が無難に感じます。
新たに生んだ問題4: localStorage と XSS の相性の悪さ
そして、保管場所としての localStorage の問題です。これは JWT そのものの欠陥というより、「読める形のトークンを JS が触れる場所に置く」という選択の問題なのですが、JWT の流行と localStorage 保管がセットで広まったため、まとめて語られることが多いです。
localStorage は同一オリジンの JavaScript から自由に読み書きできます。これは便利な反面、ページに読み込まれる JS のどれか一つでも汚染されれば、トークンが読み取られうる、ということでもあります。自社コードだけでなく、解析タグ、広告、CDN 経由のライブラリ、これらすべてが潜在的な経路になります。
XSS が一度成立すると、攻撃者は localStorage.getItem("token") でトークンを取り出し、外部に送信できます。一度のスクリプト実行で長命なトークンが丸ごと流出する、という構造的な弱さがあるわけです。後述の HttpOnly Cookie は、この「JS から読める」という点をそもそも塞ぐ、という発想になります。
なぜ HttpOnly / Secure / SameSite が生まれたのか
Cookie 側にも、攻撃への対処として属性が追加されてきた歴史があります。それぞれがどの攻撃を防ぐために生まれたのかを押さえておくと、設定の意味が見えやすくなります。
HttpOnly — XSS によるトークン窃取への対処
HttpOnly 属性をつけた Cookie は、JavaScript の document.cookie から読めなくなります。これは、XSS が成立してもスクリプトから Cookie の値を直接抜けないようにするためのものです。
ここが localStorage との決定的な違いです。localStorage は JS から読めることが前提の仕組みなので、HttpOnly に相当する保護がそもそもありません。HttpOnly Cookie なら、XSS が通っても「トークンの値そのもの」を盗まれにくくなります。
ただし注意したいのは、HttpOnly は XSS を無害化するわけではない、という点です。攻撃者は Cookie の値を読めなくても、被害者のブラウザ上で、その Cookie が自動で付くリクエストを発行できます。つまり「トークン窃取」は防げても「被害者になりすました操作」は防げません。あくまでトークン窃取耐性が一段上がる、という整理が現実に近いと思います。
Secure — 平文経路での漏洩への対処
Secure 属性をつけた Cookie は、HTTPS の接続でしか送信されません。これは、平文の HTTP 通信を盗聴されて Cookie が漏れる経路を塞ぐためのものです。常時 HTTPS が当たり前になったいまでも、つけ忘れると思わぬ経路で平文送信が起きうるので、基本的には必ずつける属性として扱われています。
SameSite — CSRF への対処と、デフォルトが変わった経緯
SameSite 属性は、CSRF(Cross-Site Request Forgery)への対処として導入されました。CSRF は、別サイトに仕込まれたリクエストが、ブラウザの「Cookie を自動で付ける」挙動を悪用して、利用者の意図しない操作をサーバに実行させる攻撃です。
SameSite は、クロスサイトのリクエストに Cookie を付けるかどうかを制御します。
-
SameSite=Strict— クロスサイトからのリクエストには一切 Cookie を付けない -
SameSite=Lax— トップレベルのナビゲーション(リンククリックなど)では付けるが、クロスサイトの POST や埋め込みリクエストには付けない -
SameSite=None— クロスサイトでも付ける(その代わりSecureが必須)
ここで大きかったのが、ブラウザ側の既定値の変更です。かつて SameSite を指定しない Cookie は、事実上 None 相当として扱われ、クロスサイトでも送信されていました。これが、主要ブラウザで「指定なしの場合は Lax 相当として扱う」という方向に変わりました。
この変更の意味は小さくありません。それまで CSRF 対策はアプリ側が CSRF トークンなどで明示的に行うものでしたが、既定が Lax になったことで、ブラウザ側がある程度のクロスサイト送信を最初から抑えてくれるようになりました。CSRF 対策のデフォルトが、アプリ任せからブラウザ標準の側に一歩寄った、という流れです。
SameSite=Lax はトップレベルナビゲーションでは Cookie を送るので、CSRF を完全に防ぐわけではありません。フォーム POST やセンシティブな操作には、別途 CSRF トークンを併用するほうが安心です。
なぜ今「Cookie 回帰」なのか
ここまでの流れを踏まえると、いまの揺り戻しは「Cookie が再評価される条件がそろった」結果として見えてきます。
前提が変わった
JWT 流行を支えていた条件のいくつかが、すでに変わっています。
サードパーティ Cookie の制限が一段落し、ファーストパーティ Cookie の運用ノウハウが蓄積されました。Safari の ITP や主要ブラウザの SameSite 既定値変更を経て、Cookie の取り回しがむしろ整理された、という面もあります。
そして、Next.js の App Router や Server Actions、Remix のようなサーバ中心のフレームワークが標準になってきました。クライアント側でトークンを保持しなくても、サーバ側で Cookie を見れば済むケースが増えています。「ブラウザに認証情報を渡さなくても認証が回る」構成が組みやすくなった、というのが大きいです。
Refresh Token Rotation の普及
JWT 運用の弱点だった失効と漏洩検知を補う作法として、リフレッシュトークンのローテーションが定着してきました。RFC 9700(OAuth 2.0 Security Best Current Practice)にも整理されています。
考え方は、「同じリフレッシュトークンを使い回さず、使うたびに新しいものに差し替える。そして一度使ったトークンが再度使われたら、漏洩を疑って関連トークンをまとめて失効させる」というものです。
async function rotateRefreshToken(oldToken: string) {
const record = await store.find(oldToken);
if (!record) {
// 未知のトークン
throw new Error("invalid_grant");
}
if (record.usedAt) {
// 一度使われたトークンが再び使われた = 漏洩の疑い
// 同じファミリ(同一ログインに由来するトークン群)をまとめて失効させる
await store.revokeFamily(record.familyId);
throw new Error("reuse_detected");
}
await store.markUsed(oldToken);
const next = await issueNewRefreshToken(record.userId, record.familyId);
return next;
}
ポイントは「一度使ったトークンを覚えておき、同じものが再度来たら家族ごと失効させる」という挙動です。トークンが盗まれた場合、盗んだ側と正規ユーザの側のどちらかが必ず再利用にあたるので、検知の足がかりになります。ステートレスを売りにしていた JWT に、結局あえて状態を持ち込んで漏洩検知を成立させている、という点が興味深いところです。
BFF パターン
BFF(Backend for Frontend)は、ブラウザと外部 API のあいだに自前のサーバ層を一枚はさむ構成です。
ブラウザ <-- 同一オリジンの HttpOnly Cookie --> 自前 BFF サーバ <-- JWT (Bearer) --> 外部 API
ブラウザと BFF のあいだは、同一オリジンの HttpOnly Cookie だけでやり取りします。外部 API との JWT のやり取りは BFF の内側で完結するので、JWT はブラウザに一切出ていきません。「JWT は使うが、ブラウザには渡さない」という分離が、この構成で実現できます。XSS が成立しても、ブラウザ側に盗める価値のあるトークンが存在しない、という状態を作れるわけです。
Token Binding / セッションバインディング
トークンを発行したクライアントにだけ使えるよう縛る発想は、Token Binding(プロトコル本体が RFC 8471、HTTP への適用が RFC 8473)として標準化が試みられた経緯があります。ブラウザ実装の事情で広くは普及しませんでしたが、その問題意識は別の形で生き続けています。
- TLS のクライアント情報をハッシュにしてトークンに紐付ける
- デバイスフィンガープリントと組み合わせる
- パスキー(WebAuthn)でセッション昇格時に再認証する
Token Binding そのものを期待するよりも、「セッションを発行した文脈と利用文脈をなんらかの形で結びつける」という発想で取り入れると、現実に落とし込みやすいと思います。
結局のところ、XSS と CSRF のどちらを重く見るか
ここがこのテーマの本質的なトレードオフだと考えています。保管場所の選択は、突き詰めると「XSS と CSRF のどちらをより警戒するか」に行き着きます。
-
localStorage保管は、CSRF には強いです。トークンを明示的にヘッダに乗せる方式なので、ブラウザが Cookie を自動送信することに起因する CSRF が起きにくいからです。その代わり XSS には構造的に弱く、スクリプトが通れば一発で抜かれます - HttpOnly Cookie 保管は、XSS によるトークン窃取には強いです。JS から値を読めないからです。その代わり Cookie が自動送信される性質から CSRF に注意が必要で、SameSite や CSRF トークンで別途守る必要があります
そして、この二つの攻撃の「踏まれやすさ」と「被害の大きさ」を比べたとき、多くの現場で XSS のほうを重く見る判断に傾いています。XSS は外部 JS の混入経路が多く成立しやすいうえ、成立すると長命トークンが丸ごと流出して被害が一気に広がります。CSRF は SameSite の既定値変更で守りやすくなったこともあり、相対的に対処しやすい、という見立てです。
この「XSS をより重く見る」という重心の置き方が、いまの「HttpOnly Cookie を基本に」という流れの背後にあります。逆に言えば、脅威モデルが違えば結論も変わりうる、ということでもあります。
これから新規実装するならどう選ぶか
一律にこれ、という結論にはなりにくいので、よくあるパターン別に整理してみます。あくまで一例として読んでいただければ十分です。
Cookie 設定の基本形
ブラウザに渡す認証 Cookie には、基本的に次の属性を全部つけておく前提で考えると整理しやすいです。
Set-Cookie: session=abc123;
HttpOnly;
Secure;
SameSite=Lax;
Path=/;
Max-Age=3600
サーバ側の擬似コードで書くと、こんな雰囲気になります。
// Node.js / Express 風の擬似コード
res.cookie("session", sessionId, {
httpOnly: true, // JS から触らせない(XSS でのトークン窃取を抑える)
secure: true, // HTTPS でのみ送る
sameSite: "lax", // クロスサイトからの送信を抑制(CSRF 対策の一部)
path: "/",
maxAge: 60 * 60 * 1000, // 1時間
});
クッキー名に __Host- プレフィックスをつけておくと、Secure 属性・Path=/・Domain 属性を指定しないこと(かつ HTTPS 由来であること)をすべて満たさない限り、ブラウザがその Cookie を受け付けなくなるので、条件の付け忘れによる事故を防げます。なお Domain を付けるとこのプレフィックスの保護が無効になる点に注意してください。新規実装ではこちらをデフォルトにしておくと安心です。
Set-Cookie: __Host-session=abc123;
HttpOnly;
Secure;
SameSite=Lax;
Path=/
パターン A: 同一オリジンで完結する Web アプリ
Next.js / Remix / Rails / Laravel のような、フロントとサーバが同一ドメインで動くアプリです。
- ブラウザに渡すのは HttpOnly + Secure + SameSite=Lax の不透明なセッション Cookie
- サーバ側は Redis などのセッションストアで状態を持つ
- 外部 API 呼び出しが必要な場合のみ、サーバ側で JWT を扱う
この構成だと、ブラウザに JWT が出ていきません。XSS でも盗めるのは Cookie の存在だけで、トークン本体は守れます。失効もセッションストアを消せば即時に効きます。先述のセッションストア共有問題は残りますが、いまは Redis 系の運用が枯れているので、当時ほどの重さは感じにくくなっています。
パターン B: モバイルアプリや別ドメイン SPA + バックエンド API
ブラウザの Cookie 文脈が使えない、あるいは使いにくいケースです。
- アクセストークンは短命(数分〜十数分)にして、メモリ保持を基本にする
- リフレッシュトークンはセキュアな OS ストレージ(Keychain / Keystore)に置く
- 必ず Refresh Token Rotation を有効にする
- 可能であればデバイスバインディング(端末固有の鍵で署名)まで踏み込む
localStorage を使う場合でも、長命のリフレッシュトークンを直接置かない、というのは押さえておきたいラインです。アクセストークンを短命にしておけば、仮に漏れても被害の窓が小さく済みます。
パターン C: マイクロサービス間や B2B API
サーバ間でしか流通しないトークンなら、JWT の利点が素直に効きます。
- mTLS で経路を縛ったうえで、JWT を Bearer として送る
- スコープを最小化し、有効期間も短く切る
- 監査ログでトークンの
jtiを追えるようにしておく
ブラウザ起因の脅威モデルとは別物として扱えるので、こちらは JWT の使いどころとして自然です。冒頭で触れた「サービス間で持ち回れる自己完結した認証情報」という JWT 本来の強みが、ここでは素直に活きます。
判断軸のまとめ
選定で迷ったときに見直している観点は、だいたい次のあたりです。
- そのトークンはブラウザの JS が触れる場所に置かれるか
- 即時失効はどのくらいの速さで欲しいか
- 監査ログでどのトークンか追える必要があるか
- XSS と CSRF のどちらを、この構成ではより重く見るべきか
- 攻撃の入口になりやすい層から、設計上の防御線を引けているか
このあたりを順に確認していくと、「ここは Cookie、ここは JWT」という分け方が自然に見えてきます。
まとめと参考リンク
「localStorage に JWT を置くな」というフレーズは、強い言い方ではあるものの、現場での痛い経験を経て生まれてきた経験則のように感じます。その背後には、Cookie セッションがサーバ側状態の安心感と引き換えに共有ストア問題を抱えていたこと、JWT がそれを解こうとして失効や保管場所という新しい問題を生んだこと、そして XSS と CSRF のどちらを重く見るかというトレードオフがあります。
一方で「JWT 全否定」ではなく、ブラウザ境界では Cookie、サーバ間では JWT、と層を分けて考えると、これまでの議論はだいぶ整理しやすくなると思います。新規実装の方は、まず HttpOnly Cookie + Refresh Token Rotation を出発点にして、要件に応じて JWT を足していく、という順番で考えてみてもよいかもしれません。
本記事はあくまで一般的な整理です。実際の設計では、対象サービスの脅威モデル、コンプライアンス要件、運用体制によって最適解は変わります。ここで挙げたパターンも出発点の一つとして読んでいただければと思います。
HttpOnly Cookie や SameSite はトークン窃取耐性や CSRF 耐性を上げる手段であって、XSS 対策そのものではありません。CSP、入力サニタイズ、テンプレートの自動エスケープなど、本体側の対策と組み合わせて使うのが前提になります。
参考リンク
- Webセッション管理の歴史とJWT・Cookieの使い分け(Zenn / khale 氏)
- RFC 9700: Best Current Practice for OAuth 2.0 Security
- RFC 6749: The OAuth 2.0 Authorization Framework
- RFC 6819: OAuth 2.0 Threat Model and Security Considerations
- RFC 7519: JSON Web Token (JWT)
- RFC 8471: The Token Binding Protocol Version 1.0
- RFC 8473: Token Binding over HTTP
- OWASP: HTML5 Security Cheat Sheet (Local Storage)
- OWASP: JSON Web Token for Java Cheat Sheet(alg=none などの注意点)
- OWASP: Cross-Site Request Forgery Prevention Cheat Sheet
- MDN: Set-Cookie - SameSite 属性
- web.dev: SameSite cookies explained