DevKitLab Logo DevKitLab
JWT / セキュリティ / フロントエンド / 認証

JWT はブラウザのどこに保存すべき?localStorage、cookie、そして XSS/CSRF のトレードオフ

JWT をブラウザのどこに置くかは、しばしば「どれが安全か」という順位づけのように問われるが、順位の問題ではない。localStorage、cookie、メモリは、二つの問い——JavaScript がトークンを読めるか、ブラウザが自動で付けるか——に別々の答えを返し、それがそのまま XSS と CSRF に対応する。本稿は各方式、業界が落ち着いた形、そしてどの保存先を選ぶかより XSS を止めることのほうが重要な理由をたどる。

本シリーズの最初の二回はサーバーの話だった。トークンをどう読むか、そしてどう正しく検証して欺かれないかだ。今回はブラウザに移る。あなたのシングルページアプリ(SPA)が JWT を受け取ったら、それをどこかに保管しなければならない。そしてこの「どこに」という問いは、Web 認証で最も激しく議論される話題のひとつ——たいてい間違った理由で——である。

議論はまるで、ある保存場所がほかより単純に「より安全」だという順位づけのように交わされる。だが違う。どの選択肢も、互いに独立した二つの問いへの答え方で性格が決まる。ページ上で動く JavaScript がトークンを読めるか、そしてブラウザがそれをリクエストに自動で付けるかだ。前者はクロスサイトスクリプティング(XSS)、後者はクロスサイトリクエストフォージェリ(CSRF)の話である。保存方式ごとに答えが違うので、正しい選択はあなたがどちらの攻撃にさらされやすいかで変わる——そして何より、XSS をそもそもページに入れずに済むかどうかで変わる。この最後の点こそ、多くの議論があえて避けて通るところなので、意図して最後に積み上げていく。

二種類の攻撃者

保存方式を比べる前に、この二つの脅威をきれいに切り分けておきたい。絶えず混同されるからだ。

XSS は、攻撃者が自分の JavaScript をあなたのオリジンの中で動かすことだ——エスケープ漏れのユーザー入力、汚染された第三者スクリプト、脆弱な依存を通じて。そうなれば、その攻撃コードはあなた自身のコードとまったく同じ権限で動く。あなたのスクリプトが読めるものは何でも読め、アプリが呼べるエンドポイントは何でも呼べる。XSS はあなたのページ内でのコード実行だ。

CSRF は別物で、ある意味でより限定的だ。攻撃者自身のサイトが、被害者のブラウザにあなたの API へ認証済みリクエストを飛ばさせる。これが成立するのは、ブラウザが環境的な資格情報——多くの場合 cookie——をあなたのドメイン宛てのリクエストに自動で付けるからで、たとえそのリクエストが evil.example から始まっても付ける。攻撃者はトークンも見えなければ応答も見えない。ただ、ブラウザが代わりに差し出す資格情報を使って、ある操作を起こさせるだけだ。

この区別を握っておこう。XSS はページの内側から読み、行動する。CSRF は外側からリクエストを偽造し、ブラウザが資格情報を一緒に運んでくれることに頼る。保存の選択肢は、ほぼ完全にこの二軸に沿って並ぶ。

各方式が何をさらすか

localStorage と sessionStorage。 ほとんどの SPA チュートリアルの既定の選択で、確かな長所ひとつと、致命的な弱点ひとつを持つ。ページ上の任意の JavaScript から読めるので、localStorage.getItem('token') の一行でトークンが XSS の手に渡り、攻撃者のサーバーへ送られて、期限切れまでいつでもどこでもリプレイされうる。これが致命的な弱点だ。確かな長所はその裏返しである。この値はあなた自身のコードが(Authorization: Bearer ヘッダーとして)手動で付けるときにしかリクエストに乗らないので、ブラウザは自動送信しない。つまりクロスサイトリクエストはこれを運べない。言い換えれば localStorage は、CSRF には強く、XSS には致命的だ。(sessionStorage は寿命——タブ単位、閉じれば消える——が違うだけで、セキュリティ上の性格は同じだ。)

トークンを入れるための、httpOnly でない普通の cookie。 資格情報の置き場所としては、これは両取りの最悪だ。JavaScript が読め、かつブラウザが自動送信するので、localStorage の XSS 露出と cookie の CSRF 露出を一度に引き受けることになる。(httpOnly でない cookie は、言語設定や機微でない UI 状態、double-submit の CSRF トークンといった別の用途にはまったく妥当だ——ただトークンそのものを入れる先にはしない。)

httpOnly cookie。 httpOnly フラグは cookie を JavaScript から不可視にする。document.cookie からは見えず、XSS もトークンを読み出して持ち去れない。これは実在する価値ある性質だ。引き換えに、ブラウザはその cookie を——DomainPathSecureSameSite のスコープに従って——ドメイン宛てのリクエストに自動で付ける。これであなたは真正面から CSRF の領域に入り、CSRF 対策を足す義務を負う(後述)。だがここに、多くの助言が取り違える微妙な点があり、一段を割く価値がある。

httpOnly は窃取を止めるが、悪用は止めない

httpOnly フラグは XSS がトークンを読むのを防ぐ。だが同じ XSS がそれを使うのは防がない。あなたのページで動く攻撃者は、ページ自身から認証済みリクエストを送れる——「メールアドレス変更」エンドポイントを叩き、API キーを発行し、アプリが移せるものは何でも移す——そしてブラウザは、その一つひとつのリクエストに、合法なコードに対するのと寸分違わず httpOnly cookie を付ける。攻撃者はトークンを盗んで後から、あるいは別のマシンからリプレイすることはできないが、今この場で、ページが開いているあいだ、ユーザーとして振る舞える。

だから httpOnly は確かに役立つ。持ち去りを止めることで被害範囲を狭める——長く使える盗まれた資格情報も、攻撃者自身のインフラからのリプレイもなくなる。だが XSS を無力化はしない。これが「httpOnly cookie は XSS に対して安全にする」というよくある思い込みへの肝心な訂正だ。それはあなたを盗みにくくするのであって、侵害されているあいだ成りすまされないようにするのではない。

メモリ上に置く——ただの JavaScript 変数、あるいはモジュールレベルのクロージャ——が、この組を締めくくる。ディスクには何も書かないので、盗むべき永続的なコピーがない。タブが再読み込みされるか閉じれば、トークンは消える。自動では付かないので localStorage と同じく CSRF に強く、窃取に対しては localStorage より一段狭い。後から持ち去ってリプレイできる保存済みコピーがないからだ。これは実在するが限定的な利点だ——クロージャの変数は注入コードから直接は届かないことさえある。それでも、あなたのオリジンにいる XSS は、認証済みエンドポイントを呼び、リクエスト経路に割り込み、あるいはリフレッシュの流れで新しいトークンを捕まえられる。メモリ保存が挫くのはオフラインリプレイであって、同一オリジンの XSS がセッションを悪用するのは止められない。代償は体験にある。トークンは再読み込みのたびに消えるので、静かに新しいものを得る手立てが要る——それがまさに次回で扱う refresh token の仕組みだ。

議論が覆い隠す真実:XSS の前では保存先は無力

これらを並べると、問い全体を捉え直す規則が見えてくる。攻撃者があなたのオリジンで JavaScript を動かせるなら、どのクライアント側保存先もトークンを完全には守れない。 localStorage なら丸ごと盗まれる。httpOnly cookie なら読めはしないが、攻撃者はそれに乗ってユーザーとして振る舞う。メモリなら直接読めるコピーはないかもしれないが、XSS はなおセッションに乗れる——認証済みエンドポイントを呼び、あるいはリフレッシュされる瞬間のトークンを捕まえて。攻撃者がすでにページ上でコードを実行しているとき、それに耐えるブラウザ保存の並べ方は存在しない。

そこから、正直な対策の優先順位が導かれる。そしてそれは、この問いが普段問われる向きの逆だ。XSS を防ぐことが第一の防御であり、保存先の選択は、成功した XSS がどれだけ悪化するかを決めるにすぎない。 「トークンをここに置けば XSS に害されない」と言う者は誇張している——保存は被害の限定であって、予防ではない。だから実際にユーザーを守る作業の大半は、保存の決定の外にある。フレームワークの自動出力エスケープ(React、Vue、Angular はいずれも既定でエスケープする)に頼り、信頼できないデータへの dangerouslySetInnerHTMLv-html はどれも負債とみなす。厳格な Content-Security-Policy を配り、注入されたスクリプトができることと、盗んだトークンを送り出せる先の両方を制限する。DOM ベースの注入点を塞ぐために Trusted Types を検討する。バージョンを固定してハッシュ検証できる第三者の静的アセットは Subresource Integrity で固定する(既知のファイルを検証する仕組みなので、供給網全体の解ではなく、局所的な手段だ)。そして依存にパッチを当て続ける。ビルドの中の汚染されたパッケージ一つが、アプリの全権限を持った XSS だからだ。保存先の選択は現実の判断だが、一番目ではなく二番目だ。

httpOnly cookie を選ぶことは CSRF を引き受けることだ。だから民間の言い伝えに手を伸ばすより、本当に効くものを知っておく価値がある。

SameSite という cookie 属性が、今日の最前線だ。SameSite=Lax——いまや現行ブラウザの既定値——は、クロスサイトのサブリソースリクエストに cookie を付けず、また安全でないトップレベルのナビゲーション——あなたのアプリへ遷移するクロスサイトのフォーム POST はこれを運ばない——にも付けないが、通常のトップレベルの GET ナビゲーションでは付け続けるので、普通のリンクやログインは動く。これは無条件の障壁ではなく、強力な緩和だ。ブラウザは互換性のための抜け穴を出荷してきた(とりわけ、設定直後の cookie がトップレベルのクロスサイト POST にまだ付く、短い「Lax-allowing-unsafe」の窓)ので、扉を封じるものではなく、ハードルを上げるものと捉えよう。Strict はより厳しいが、ユーザーがクロスサイトのリンク経由で入ってきたときにさえ cookie を出し惜しむため、その最初のナビゲーションでは、同サイトのリクエストが再送するまで、アプリは単にログアウト状態に見える。None は CSRF を開き直すというより、SameSite という緩和の層をまるごと取り除く——いまでは Secure が必須で、あなたはサーバー側の CSRF 防御に頼る状態へ戻る。

SameSite は必要だが、完全な答えではない。同サイトの攻撃者には無防備だ——侵害された、あるいは攻撃者が握るサブドメインは、あなたの cookie にとって同サイトだ——歴史的な例外もあり、古いクライアントは従わない。だから多層防御はなお要る。同期トークンや double-submit の CSRF トークン、状態を変えるリクエストでの Origin/Referer ヘッダー検証、あるいはクロスサイトのフォームには付けられないカスタムヘッダーの要求(ブラウザは単純なクロスオリジンのフォームに任意のヘッダーを足させない)。対照として名を挙げておく価値がある。localStorage と Authorization: Bearer の組は、ブラウザがそのヘッダーをひとりでに付けることがないからこそ、本質的に CSRF に強い。この強さが localStorage 方式の唯一の実在するセキュリティ上の利点であり——見てきたとおり、その代償は XSS への露出だ。

二つの一般的なパターン

実務的な答えとして、二つの形が現れた。対にして理解する価値がある。

一つ目は、メモリ上の短命な access token に、httpOnlySecureSameSite の refresh cookie を組み合わせる形だ。access token は JavaScript 変数に置き——永続化しないので、再読み込みのあとに盗まれるものは残らない——Bearer ヘッダーとして送るので CSRF に強い。肝心なのは、短命であることだ。あなたが本当に守るべき長寿命の資格情報である refresh token は、リフレッシュ用エンドポイントにスコープを絞った httpOnly cookie に置き、JavaScript からは読めない。ページ読み込み時、そして access token が切れるたびに、アプリはリフレッシュ用エンドポイントを呼んで新しいものを得る。これは意図的に、長寿命の資格情報を JavaScript が読めない場所へ置き、手の届く資格情報を短命に保つ。だから攻撃者が静かに持ち去ってオフラインでリプレイできるのは、せいぜい残り数分のトークンだけになる。ただし、それが何を買い、何を買わないかは明確にしておこう。あなたのオリジンで動く XSS は、自らリフレッシュ用エンドポイントを呼び、応答から新しい access token を読み出し、セッションを保ち続けられる——そしてそのあいだ、ユーザーとして業務 API を直接呼べる。短命化が小さくするのは、別の場所でリプレイされる単一の盗まれたトークンの価値だ。生きている同一オリジンの XSS を数分に縮めはしない。この形の本当の勝ちは、refresh token を JavaScript の手の届かない所に置くことであって、XSS を無害にすることではない。それは全面的に、次回で扱う——短い寿命と、ローテーションする refresh token という——ライフサイクルの仕組みに寄りかかっている。

二つ目は、bearer token の持ち去りとオフラインリプレイに対して特に強い BFF(Backend for Frontend、フロントエンド専用バックエンド)パターンだ。ブラウザは JWT を一切持たない。SPA は自分のバックエンドとだけ話し、普通の httpOnly セッション cookie で認証される。そのバックエンドが各トークンを持ち、ユーザーに代わって下流 API を呼ぶ。これは、ブラウザアプリ向けの現行 OAuth ガイダンスが、より高いセキュリティを要するシステムに勧める形であり、その理由はまさに本稿が積み上げてきたものだ。bearer token を JavaScript の手の届く範囲から完全に取り除くので、ページには持ち去るものも、別の場所でリプレイするものもない。それでも XSS の解ではない——あなたのオリジンで動く攻撃者は、ブラウザが合法なコードのために付けるのと同じ httpOnly セッション cookie を使い、BFF 経由で認証済み操作を駆動できる。BFF が消すのはトークンの窃取とオフラインリプレイであって、同一オリジンの悪用ではない。代償は構造にある——そのバックエンドコンポーネントが要り、いくらかのサーバー側セッション状態(と、その cookie 自身の CSRF 対策)を戻すことになる——が、保存をめぐる論争に勝とうとするのではなく、それを回避する。

第一回からの念押しと、それを具体にする道具

localStorage を隠し場所のように思いがちだ。だが違う。署名済みの JWT——JWS、本シリーズとこのインスペクターが扱うもの——の payload は Base64URL エンコードで、暗号化ではなく可読だと第一回で述べた。(暗号化された JWE は例外で、ブラウザ認証では稀だ。)だから localStorage に居座るトークンは、明白に読める文字列であり、解けば明白に読める claim になる。それを JWT インスペクターに貼れば、XSS が localStorage を読んだ瞬間に見るのとまったく同じものが見える。トークン全体と、その中のあらゆる claim が、丸見えだ。(一つの区間を手で分解したいなら、Base64 エンコード/デコードツールがより低い層で同じものを見せてくれる。)これはまた、payload に機微な情報を決して入れない理由でもある——保存も、トークン自身も、それを隠しはしない。

引退させるべき三つの俗説

「入力さえきちんとサニタイズすれば localStorage で問題ない。」 サニタイズは XSS を減らすが、根絶することはまれで、注入点を一つ見落とすか、依存を一つ汚染されるだけで十分だ。リプレイできるトークン丸ごとを「うちのアプリは永遠に XSS ゼロ」に賭けるのは、しないほうがよい賭けだ——これこそ、長寿命の資格情報を JavaScript の手の届かない所に置く理由のすべてだ。

httpOnly cookie は XSS に対して安全にしてくれる。」 それはトークンが読まれ持ち去られるのを止める。ユーザーがログインしているあいだ、XSS があなたのページ内部から認証済みリクエストを送るのは止めない。httpOnly は被害を限定するが、予防はしない。

「JWT は cookie に入れておけばいい。」 どのみち状態を持つ httpOnly cookie を使うなら、そもそも JWT が要るのか問う価値がある。自己完結型のトークンが価値を出すのは、検証が無状態で、複数サービスに分散しなければならないときだ。自分の裏側とだけ話す第一者の Web アプリなら、サーバー側セッション状態に支えられた不透明な普通のセッション cookie のほうが、たいてい単純で——次回が具体的に見せるとおり——取り消しもずっと容易だ。JWT を選ぶことは、検証の構造についての判断であるべきで、反射であってはならない。

適用範囲について、手短に二点。この論争はブラウザに固有だ。ネイティブのモバイルアプリは、localStorage に類するものではなく、プラットフォームの安全な保存領域(iOS の Keychain、Android の Keystore)を使うべきだし、デスクトップや Electron のアプリにはそれぞれの注意点がある。そして、ここで述べたことは何も、通信の保護に取って代わらない——すべては、隅々まで HTTPS であることを前提にしている。それがなければ「どこに保存するか」という問い自体が意味をなさない。

この一篇が連れてきた地点

いつも上位に来る形——短命な access token に、よく守られた refresh token を組み合わせる、あるいはトークンをブラウザから完全に締め出す BFF——が下げるのは、トークンが盗まれる容易さだ。だが「下げる」は正直な言葉だ。現実の XSS の前では、いくらかの露出は残るし、トークンは別の経路からも漏れる。保存が決めるのは、トークンがどれだけ盗まれやすいかであって、盗まれた一つが世に出たあとどれだけの値打ちを持つかは決められない。その後者の問い——単一のトークンの有効寿命を短く保ち、資格情報をローテーションして窃取を検知可能にし、侵害されたセッションを止められること——こそがトークンのライフサイクルであり、まさに最終回の主題だ。短い寿命、refresh token のローテーション、そして取り消しである。