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

公開日:
目次

Xで定期的に再燃するJWT保存場所の論争を眺めていると、「localStorageは危険、Cookieは安全」「いやXSSされたら同じ」と意見が割れて、結局どっちなのか分からないまま流れていくことが多いです。気になって一次ソースと国内外の主張を読み比べてみたら、対立して見える主張は実は矛盾しておらず観点が違うだけだと分かったので整理しました。

論争が噛み合わない理由

OWASPやAuth0が言う「localStorageは危険」と、SPAのXSS耐性観点で言われる「localStorageもCookieも大差ない」は矛盾していません。前者は「トークンが外部に持ち出される」観点で、後者は「XSS下でのAPI不正呼び出し」観点で語られていて、見ている場所が違うだけです。

噛み合わない原因はもっと手前の前提にあります。議論の参加者がそれぞれ違うトークン、違うアプリ構成を想定したまま「保存場所」を語っているため、結論だけ並べると食い違って見えます。

トークンの種類で違う話になる

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

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

「JWTをlocalStorageに置くな」と言うとき、議論の主役はほぼアクセストークンです。短命なので、盗まれても悪用できるのは有効期限内に限られます。

一方で長命のリフレッシュトークンが盗まれると、再発行を繰り返して長期間悪用されます。深刻度が桁違いです。一方が「短命なら被害は限定的」と言い、もう一方が「リフレッシュトークン盗まれたら終わり」と言って、別の話のままぶつかっていることがよくあります。

アプリ構成で前提が変わる

もう一つの前提が、アプリの構成です。

  • 同一ドメインのMPA/SSR
  • SPA + 同一ドメインAPI
  • SPA + 別ドメインAPI(Firebase等)

3つでは、Cookieの使い勝手も攻撃面も変わります。SPAから別ドメインのAPIを呼ぶ構成だと、Cookieは「サードパーティCookie」扱いになり、FirefoxやSafariのブラウザ制限で発行や送信が止まります。Cookieを選びたくても構成的に選べないわけです。「Firebaseは何故localStorageを使っているのか」という疑問は、この前提を踏まえれば素直に説明がつきます。

観点を分ければ両者は両立する

OWASP/Auth0が言うこととSPAのXSS観点で言われていることは、それぞれ違う観点で正しい主張をしています。観点を切り分けるとどちらも事実として並び立つことが見えてきます。

トークンが外に持ち出される観点ではlocalStorageが弱い

OWASPのHTML5 Security Cheat Sheetは、セッション識別子をlocalStorageに保存するなと明確に書いています[1]。理由は「JavaScriptから常に読まれるから」というシンプルなものです。

Auth0のドキュメントはもう一歩踏み込んでいて、最も安全なのはブラウザメモリへの保存で、Web Workerを使うのが「トークン保護の最良の方法」と書かれています[2]。Web Workerは本体のスクリプトとは別の領域で動くため、XSSが起きてもWorker内のトークンに直接触れないという発想です。

localStorageはというと、Auth0自身が「XSSが成功すれば、localStorageに保存されたトークンは取得される」と認めています。それでも代替案として挙げているのは、iframe経由のトークン取得が必要な構成や、ブラウザのITP2でクロスドメインCookieが使えないケースの「現実的な代替手段」としてです。

ここで重要なのは、トークンが攻撃者のマシンに持ち出されたあとに何が起きるかです。リフレッシュトークンが含まれていれば、攻撃者は被害者がブラウザを閉じても新しいアクセストークンを発行し続けられます。HttpOnly Cookieであれば、Cookieは攻撃者のブラウザに送られないので、被害は被害者のセッション内に閉じます。

XSS下でのAPI不正呼び出しでは差がない

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

日本語圏でもこの観点を端的に整理した解説が出ていて[3]、PoC検証を経て「HttpOnly Cookie・localStorage・メモリ方式のいずれもXSS耐性では大差ない」という結論が示されています。

つまりXSSが起きた瞬間、攻撃者は被害者のブラウザの中から、被害者の認証情報付きで好きなAPIを呼び放題になります。dev.toの議論にもある通り、トークンをコピーして自分のマシンで使えないだけで、/admin/drop-all-the-tables を叩いたり、/admin/users から取ったデータを攻撃者サーバーに送ったりは普通にできます。

XSSが起きた前提でlocalStorageとHttpOnly Cookieにどれだけ差があるかと言われると、API不正呼び出しの観点では実質ありません。

観点別に整理する

両者を観点ごとに並べるとこうなります。

観点 localStorage HttpOnly Cookie
トークンの外部持ち出し される(攻撃者マシンで悪用可) されない
XSS下のAPI不正呼び出し 可能 可能
CSRF 構造的に発生しない SameSiteで大幅軽減
サードパーティCookie制限 関係なし 別ドメインAPIではブロック対象
リフレッシュトークン窃取の深刻度 高い(長期悪用可能) 低い

OWASP/Auth0が見ているのは主に1行目と5行目で、SPAのXSS観点で論点になるのは2行目です。どの行も事実として正しく、どちらか一方が間違っているわけではありません。

実務でどう選ぶか

観点が分かれれば、構成ごとの選び方も整理できます。

構成別の推奨

構成 推奨
同一ドメインのMPA/SSR HttpOnly Cookie + SameSite=Lax/Strict
SPA + 同一ドメインAPI HttpOnly Cookie(特段の理由がない一般ケース)
SPA + 別ドメインAPI(Firebase等) localStorage(現実的に唯一の選択肢)
最も安全寄りに振りたい メモリ + リフレッシュトークンはHttpOnly Cookie

これに加えて、構成によらない絶対NGがあります。長命のリフレッシュトークンをlocalStorageにそのまま置くことです。アクセストークンの短命化で被害が限定できる一方、リフレッシュトークンは持ち出されると長期悪用に直結します。

保存場所の議論はXSS対策の上にしか乗らない

「localStorage か Cookie か」を真剣に悩むより先にやることがあります。XSSが起きれば、保存場所がどちらでもAPIは不正呼び出しされるからです。CSPでスクリプト実行元を絞ること、フレームワークの安全な使い方を守ること、入力サニタイズを徹底すること、依存パッケージの脆弱性を継続的に監視することのほうが、保存場所選びより桁違いに効きます。

その次に効くのが、アクセストークンの寿命を短くすることと、リフレッシュトークンの使い捨て更新(=使うたびに新しい値に差し替え、古い値が再利用された瞬間に全失効する仕組み)です。IETFが策定中のOAuth 2.0 for Browser-Based Apps のドラフトもこの方向を推奨しています[4]

保存場所選びは、これらが整った前提の上で「最後の上乗せ」として効いてくる選択です。「localStorageは危険、Cookieは安全」という素朴な対立だけを切り出して語っても、XSS対策やトークン寿命の話を抜きにしては結局あまり意味を持ちません。

脚注
  1. HTML5 Security Cheat Sheet - OWASP ↩︎

  2. Token Storage - Auth0 Docs ↩︎

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

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