SPAのJWTはlocalStorageとCookieのどちらに置くか

公開日:
更新日:
目次

Xで定期的に再燃するJWT保存場所の論争が流れてきました。

論争の決着を付けたいわけではなく、自分が次にSPAを書くときに何を選ぶかを言語化しておきたかったので、自分なりに整理してみました。

長寿命情報はサーバー側、簡易ならlocalStorage

結論から書くと、「ログイン状態を維持するための長寿命情報」をどこに置くかの問題で、基本は HttpOnly Cookie に倒し、バックエンドで管理して localStorage に長寿命情報を残さないのが安全だと考えています。ただし、簡易的に作りたいときに、保存する情報を絞れば localStorage も選択肢に入ります。

ここから、なぜそう結論したかを「XSSとトークン寿命で第三者主張を整理する」「アプリ構成と主要サービスの実態を見る」「自分はどう選ぶか」の順に書いていきます。

XSSとトークン寿命で第三者主張を整理する

OWASPやAuth0、XSS実検証の主張を並べると、結論は「XSS下では保存場所だけでは決まらないが、長寿命情報になると差が出る」に収束します。

トークンの種類で話が違う

OAuth/OIDC文脈で出てくるトークンは大きく3種類あります。

種類 用途 寿命 形式
アクセストークン API呼び出し 短命(15分〜1時間) JWTで発行されることが多い
リフレッシュトークン アクセストークン再発行 長命(数日〜数ヶ月) 不透明文字列が一般的
IDトークン OIDCの認証情報 短命 JWT

「JWTをlocalStorageに置くな」という主張の主役は、ほぼリフレッシュトークンや長寿命のセッション情報です。アクセストークンのような短寿命のものを localStorage に置くこと自体は、有効期限が短いぶん被害が限定的で、セキュリティ上そこまで深刻な話にはなりません。「localStorageは危険」と「localStorageもCookieも大差ない」が噛み合わないのは、別の寿命のトークンを想定したまま語っているからだと思います。

この議論の発端になりがちなのは Firebase などクライアントサイドで完結する認証サービスの話で、これらはサーバー層を介さずブラウザから直接認証エンドポイントを叩く構造なので、長寿命のリフレッシュトークンまでブラウザに置かざるを得ないという事情があります。

XSS実行中はどちらも止められない

XSSによる被害は「保存された情報の盗難」よりも「APIの不正呼び出し」によって発生します。HttpOnly Cookie であっても、XSSで動く不正な fetch や XHR には認証 Cookie が自動で乗ります。正当な API 呼び出しに Cookie が必要である以上、これを止める手段がないからです。

日本語圏でもこの観点を端的に整理した解説が出ていて[1]、PoC 検証を経て「HttpOnly Cookie・localStorage・メモリ方式のいずれも XSS 耐性では大差ない」という結論が示されています。攻撃者は被害者のブラウザの中から、被害者の認証情報付きで好きな API を呼び放題になります。

OWASP の HTML5 Security Cheat Sheet も、セッション識別子を localStorage に保存するなと明確に書いています[2]。Auth0 はもう一歩踏み込んで、最も安全なのはブラウザメモリへの保存で、Web Worker を使うのが「トークン保護の最良の方法」と書いています[3]。XSS が成功すれば localStorage の内容は取得されるが、メモリや Worker 越しなら直接触れないという理屈です。

持ち出されて長期間使われるかで差が出る

ただし「攻撃がいつまで続くか」と「持ち出されたトークンを後から無効化できるか」では保存場所で差が出ます。

HttpOnly Cookie の場合、攻撃者は被害者のブラウザ内で動くスクリプトを通してしか API を叩けないので、被害者がタブを閉じれば攻撃も止まります。サーバー側で対象セッションを失効させれば、その時点で持ち出された経路も止まります。

localStorage に置かれたトークンは違います。XSS で一度読み出してしまえば、攻撃者は自分のマシンに持ち帰り、トークンの有効期限が来るまで好きな端末から叩けます。被害者がブラウザを閉じても、ユーザーがログアウトしても、トークン自体に有効期限まで有効な署名が乗っていれば寿命までは使われ得ます。リフレッシュトークンまで持っていかれていれば、寿命を更新し続けられます。

アクセストークン(短寿命)に限れば影響は寿命の数十分で済みますが、リフレッシュトークン(長寿命)になると影響期間が桁違いになります。OWASPやAuth0が「localStorageは危険」と言うときに見ているのも、結局はこの「持ち出されると長期間悪用される情報をブラウザの読める場所に置くな」という主張だと自分は読んでいます。

つまり議論の本当の問いは「localStorageが安全か」ではなく「リフレッシュトークンをブラウザの読める場所に置いていいか」のほうです。

守る情報の性質で評価が変わる

もう一段、置こうとしている情報そのものの性質でも評価が変わります。

ログイン直後からずっとブラウザに置いてあって、ほぼ変更されない情報は、XSS で API を叩かれた時点で事実上盗まれているのと変わりません。攻撃者は被害者のブラウザの中から好きな API を呼べるので、画面に表示できる情報なら同じように引き出せます。この場合は Cookie でも localStorage でも被害の差は大きくないことが多いです。

一方、ログインからしばらく経ってから追加され、かつ盗まれてはいけない情報は、保存場所の差がそのまま被害の差になります。リフレッシュトークンのような「長期間有効で再発行を繰り返せる情報」がまさにこれにあたり、ここは HttpOnly Cookie に倒すのが妥当です。

OWASP自身も立場が割れている

ここまで OWASP を第三者の基準として引いてきましたが、その OWASP 自体がここ数年で立場を動かしていて、しかも内部の文書同士で結論が食い違っています。

古い ASVS(Application Security Verification Standard)V4 は、セッショントークンは「適切に保護された Cookie か HTML5 の sessionStorage のような安全な方法でのみ保存せよ」としていて、localStorage は明示的に禁止、sessionStorage は許可という線引きでした。

これに対して、sessionStorage 推奨だとマルチタブ運用が壊れるという指摘が挙がります[4]。タブごとにストレージが分かれる sessionStorage では、新しいタブを開くたびにセッションが引き継がれず使い物にならない、という現実的な問題です。この議論を経て、「localStorage もセッション保存に許可すべき。ただし idle/absolute timeout の併用を条件とする」という方向へ傾きました。

現行の ASVS V5 は、localStorage を含むブラウザストレージへのセッショントークン保存を条件付きで容認しています。idle タイムアウトと absolute タイムアウトの併用が前提です。理由は「現実に広く使われており、XSS リスクは他の手段で緩和できる」という現実寄りの判断です。

ところが、同じ OWASP の Session Management Cheat Sheet は現行版でも強硬なままです。認証トークン・セッションID・JWT・リフレッシュトークンの類を localStorage や sessionStorage に保存するなと明記し、推奨は HttpOnly・Secure・SameSite=Strict な Cookie、もしくは Backend-for-Frontend(BFF)パターンだとしています[5]

理想論(Cheat Sheet の「BFF が正解」)と現実論(ASVS の「条件付きで容認」)の両方が、本家から公式に発信されている状態です。OWASP ですら立場が割れているのだから、X で宗教論争が決着しないのはむしろ当然の構造だと思います。

アプリ構成と主要サービスの実態を見る

3層に分けて見ると、選び方は自分の構成で大筋が決まるのが分かってきます。判断軸はシンプルで、サーバー層(BFFやSSR)を置けるかどうかにほぼ集約されます。

サーバー層を持てる場合、長寿命情報はサーバー側に置き、ブラウザには HttpOnly Cookie でセッション識別子を渡すのが安全です。リフレッシュトークンをサーバーで管理できれば、ブラウザに長寿命の認証情報を残さずに済みます。

Supabase の SSR モード、Clerk、Auth0 の BFF パターン、自前で Hono などの BFF を立てる構成はいずれもこの選択肢に乗ります。アプリと API を同一サイトに収められるので、サードパーティ Cookie の制限も問題になりません。「SPA だから localStorage」と言われがちですが、SPA でもサーバー層さえ持てば fetch('/api', { credentials: 'include' }) で Cookie が乗りますし、JS がトークンを触る必要もなくなります。

SPA のみなら localStorage しかない

サーバー層を持たず SPA だけで動かす構成では、HttpOnly Cookie を発行する主体がいないので、localStorage(あるいは IndexedDB)に置くしかありません。

Firebase Auth が IDトークンとリフレッシュトークンを IndexedDB に置いているのは、バックエンドを介さず直接 identitytoolkit.googleapis.com を叩く構造上の必然です。リフレッシュトークンも「ユーザー削除・無効化・パスワード変更等の重大なアカウント変更」が起きるまで失効しない設計になっています[6]

別ドメインの API を直接叩く構成だと、Cookie はサードパーティ Cookie 扱いになり Firefox や Safari のブラウザ制限で発行や送信が止まります。Cookie を選びたくても構成的に選べない、というのがこの状況です。

主要サービスの保存場所と寿命

代表的な認証サービスがどこに何を置いているかを並べてみます。

サービス デフォルト保存場所 リフレッシュトークン
Firebase Auth IndexedDB 主要なアカウント変更まで失効しない
Amplify (Cognito) localStorage(CookieStorage に切替可) Cognito 側で設定(既定30日)
Supabase(SPA) localStorage ローテーション
Supabase(SSR) HttpOnly Cookie サーバー管理
Clerk HttpOnly Cookie(FAPI サブドメインで同一サイト化) サーバー管理

Amplify は localStorage が既定ですが CookieStorage への切替が用意されています[7]。Clerk は最初から FAPI サブドメインでアプリと同一サイト関係を作り、Cookie 前提のハイブリッドモデルで設計されています[8]。Supabase は SPA モードと SSR モードで保存場所が違い、SSR モードでは HttpOnly Cookie に倒しています。

業界の流れとしては、「サーバー層を置いて Cookie で完結」が確実に主流寄りに動いています。Supabase が SSR モードで Cookie を既定にし、Clerk が最初から Cookie 前提で設計され、Amplify や Auth0 が「localStorage か Cookie か」を構成側で選ばせる方向に揃っているのは、その表れだと思います。

簡易構成は短寿命だけlocalStorageに置く割り切り

バックエンドが持てる場合でも、運用や実装の手数を減らしたいなら、保存する情報を絞って localStorage を使う割り切り方もあります。たとえば短寿命のアクセストークンだけ localStorage に置き、リフレッシュトークンは持たせず必要に応じて再ログインを促す構成にすれば、被害の範囲はコントロールできます。

自分の選び方は構成と情報の性質で決める

ここまでを踏まえて自分の選び方をまとめると、こうなります。

  • バックエンドを置けるなら、長寿命情報はサーバー、ブラウザには HttpOnly Cookie。これを第一候補にする
  • SPA のみで動かす制約があるなら localStorage を腹を括って選び、XSS 対策とトークンの寿命設計を最大限固める
  • 簡易的に作りたいなら、長寿命情報はブラウザに残さない方針で短寿命トークンだけ localStorage に置く、といった割り切りもあり

「自分は XSS 対策を強気にやれるから、どんな場合も localStorage で十分」というスタンスもあると思います。ただ、保存場所選びの前に効くのは CSP やフレームワークの安全な使い方、入力サニタイズ、依存パッケージの脆弱性監視で、その次に効くのがアクセストークン寿命の短縮とリフレッシュトークンローテーション(使うたびに新しい値に差し替え、古い値が再利用された瞬間に全失効する仕組み)です。IETF が策定中の OAuth 2.0 for Browser-Based Apps のドラフトもこの方向を推奨しています[9]

これらが整っていない状態で localStorage を選ぶと事故が起きやすく、自分のように「セキュリティに極端な自信があるわけではない」立場では、無難に HttpOnly Cookie を選んでおくほうが結果的に楽だ、というのが今の結論です。

脚注
  1. JWTのlocalStorage保存に関する見解 - mond ↩︎

  2. HTML5 Security Cheat Sheet - OWASP ↩︎

  3. Token Storage - Auth0 Docs ↩︎

  4. OWASP/ASVS Issue #2010 ↩︎

  5. Session Management Cheat Sheet - OWASP ↩︎

  6. Manage User Sessions - Firebase Auth (2026-05-25 アクセス) ↩︎

  7. Tokens and credentials - AWS Amplify (2026-05-25 アクセス) ↩︎

  8. How Clerk works - Clerk Docs (2026-05-25 アクセス) ↩︎

  9. OAuth 2.0 for Browser-Based Applications (IETF draft) ↩︎