ブラウザの保存領域 — どこに置くかで何が変わるか
値をブラウザに置く場所は 1 つではありません。どれを選ぶかで、サーバーに届くか、別タブから見えるか、いつ消えるかが変わります。
置き場を間違えたときの症状は分かりにくい形で出ます。ログイン状態が別タブに伝わらない、http では動くのに https で値が消える、容量が足りないと言われる。どれも「保存できたかどうか」ではなくどの単位で分かれているかの問題です。
この章で学ぶこと
- 3 つの機構を「サーバーへ届くか」「何で分かれるか」「いつまで残るか」「容量の制限がどうかかるか」「タブ間で共有されるか」の 5 軸で比べる
- クッキーと Web Storage で分かれる単位が正反対であること
- 容量の数値が下限と上限で向きが逆であること
- 認証トークンをどこに置くか
オリジンと CORS のオリジンの 3 つ組と、クッキー の属性を前提にします。この章はその 2 つの境界に、保存領域という 3 つ目を並べます。
この章で扱わないこと
| 観点 | 参照先 |
|---|---|
| 5 MiB に収まらないデータと、オフラインで動かす仕組み | IndexedDB と Cache API |
| 保存したデータが消える条件 (退避の機構) | 同上 |
| 他のサイトに埋め込まれたときの分かれ方 | 同上。実装はオリジンだけを鍵にしていない |
| SPA でのトークン保管とリフレッシュフローの実装 | React ガイド — SPA の認証 |
| クッキーの属性そのものの意味 | クッキー |
3 つの機構を 5 つの軸で比べる
比べる対象は localStorage / sessionStorage / クッキーの 3 つです。軸ごとに小さい表を置きます。1 つの大きな表にまとめると、軸によって意味の違う値が同じ列に並んで誤読を生みます。
1. サーバーへどう届くか
| 機構 | サーバーへの届き方 |
|---|---|
localStorage | 届かない。コードが明示的にボディやヘッダーへ載せたときだけ |
sessionStorage | 同上 |
| クッキー | ブラウザが条件に合えば自動で付ける |
差がはっきり出るのはサーバーサイドレンダリングの初回 HTML です。サーバーが最初の HTML を組み立てる時点で読めるのはクッキーだけで、Web Storage はブラウザの中にしかありません。初回描画をログイン状態で出し分けたいなら、判定材料はクッキーに置く必要があります。
2. 何で分かれるか
| 機構 | 分ける鍵 | scheme | port |
|---|---|---|---|
localStorage / sessionStorage | オリジン | 含む | 含む |
| クッキー | host + path | 見ない (Secure で絞れる) | 見ない |
26 章は「クッキーのスコープはオリジンではない」と書きました。Web Storage はその逆で、オリジンそのものです。同じ host でも scheme が違えば別の領域になります。
https://example.com で書いた localStorage の値は
http://example.com からは読めない (別のオリジン)
MDN は「for a site loaded over HTTP … localStorage returns a different object than … over HTTPS」と述べています。port については仕様が storage key をオリジンの組と定めるので含まれますが、MDN が挙動として明示するのは scheme だけです。
この非対称がこの部の主題です。 同じ「保存する」でも、クッキーは scheme と port を無視して広く届き、Web Storage は両方を見て狭く分かれます。片方の感覚でもう片方を扱うと事故になります。
3. いつまで残るか
| 機構 | 消えるとき |
|---|---|
localStorage | 期限を持たない |
sessionStorage | タブを閉じるまで |
| クッキー | Expires / Max-Age の指定時。どちらも無ければブラウザを閉じるまで |
MDN は localStorage について「the stored data is saved across browser sessions」「localStorage data has no expiration time」と述べています。
ただし期限を持たないことは、消えないことではありません。ブラウザは保存領域が逼迫すると古いものから捨てますし、追跡防止の一環で一定期間使われていないデータを消す実装もあります。この機構は IndexedDB と Cache API が扱います。
4. 容量の制限がどうかかるか
数値だけを並べると誤読します。何に対する値かと、下限か上限かを各行に書きます。
| 機構 | 値 | 何に対する値か | 向き |
|---|---|---|---|
localStorage | 5 MiB | オリジンごとの領域全体 | 上限 |
sessionStorage | 5 MiB | 同上 | 上限 |
| クッキー | 4096 バイト | 1 個あたり (名前 + 値 + 属性の合計) | 下限 |
| クッキー | 50 個 | 1 ドメインあたり | 下限 |
クッキーの値は RFC 6265 が定める「実装が満たすべき最小要件」で、上限ではありません。実装はこれ以上を許してかまいません。一方 Web Storage の 5 MiB は Storage Standard が保存先ごとに割り当てる上限です。
かかる対象が「1 個」と「領域全体」で違い、向きも逆です。 同じ制限として読むと、クッキーに 4 KB 弱まで詰め込めば安全だと考えたり、Web Storage の 5 MiB を 1 キーあたりの値だと誤解したりします。
localStorage と sessionStorage の合計を「10 MiB」と数えることもできません。sessionStorage はタブごとに分かれるので、オリジンあたりの合計として足し合わせる意味がないからです。
5. タブ間で共有されるか
| 機構 | 別のタブから見えるか |
|---|---|
localStorage | 見える。他タブの変更は storage イベントで届く |
sessionStorage | 見えない。タブごとに独立する |
| クッキー | 見える |
sessionStorage を分ける単位は、仕様では「タブ」ではなく traversable navigable です。ブラウザの UI としてのタブとほぼ一致しますが、規定しているのは閲覧の履歴をたどれる単位のほうです。Storage Standard は「Session storage buckets must be cleared as traversable navigables are closed」と定めます。
MDN は「Opening a page in a new tab or window creates a new session … which differs from how session cookies work」と述べています。window.open で開いた先には開いた時点の値がコピーされますが、以後は別々に動きます。
HttpOnly は保存先ではありません
HttpOnly を「4 つ目の保存場所」のように並べる説明を見かけますが、これはクッキーの属性です。上の表でクッキーの行に含まれます。
そして塞ぐのは読み取りだけです。クッキーはリクエストに自動で付くので、XSS が起きたスクリプトは値を読めなくても、そのクッキーが付いたリクエストを送れます。26 章の「よくある誤解」が打ち消したこの誤読を、保存先の比較表で復活させないでください。
保存しないという選択
値を JavaScript の変数に持つだけで、どこにも保存しない方法があります。上の 5 軸の表には並べていません。軸 1 では Web Storage と同じ、軸 3 に該当する値がなく、軸 4 と軸 5 も空欄になるからです。
ただし認証トークンの置き場を考えるときは比較対象になります。
| 性質 | メモリ保持 |
|---|---|
| ページを再読み込みしたとき | 消える |
| ディスクに残るか | 残らない |
| XSS で読まれるか | 読まれる (同じ実行コンテキストにある) |
「保存しない」は XSS への対策にはなりません。効くのは、端末に残った値を後から拾われる経路だけです。
認証トークンをどこに置くか
OWASP の Session Management Cheat Sheet は、資格情報の保存先を名指しで禁じています。
Do not store authentication tokens, session IDs, JWTs, refresh tokens, or any credential in
localStorageorsessionStorage.
理由は軸 5 と HttpOnly の話につながります。Web Storage は JavaScript から読めるので、XSS が起きた時点で値が持ち出されます。HttpOnly のクッキーなら値は読めません。持ち出されないぶんだけ被害が小さくなります。
置き場を決める手順は次のとおりです。
短命なアクセストークンをメモリに持ち、長命なリフレッシュトークンを HttpOnly のクッキーに置く構成が、この手順の帰結です。React ガイド — SPA の認証 が実装を扱います。
5 MiB に収まらないときは
3 つの機構はどれも大きいデータに向きません。理由は機構ごとに違います。
- Web Storage は領域全体で 5 MiB が上限で、API も同期です。大きい値を読み書きするとその間メインスレッドが止まります
- クッキーの制限はさらに小さく、しかもリクエストに自動で付くので、大きい値を置くと通信そのものが重くなります
「5 MiB に収まらないもの」と「オフラインで動かしたいもの」が次の章の入口です。
よくある誤解
「localStorage は https と http で同じ値を共有する」 — しません。Web Storage の鍵はオリジンで、scheme を含みます。クッキーの感覚で考えると取り違えます。
「クッキーは 1 ドメイン 4096 バイトまで」 — 4096 バイトは 1 個あたりで、しかも実装が満たすべき下限です。個数の下限は 1 ドメイン 50 個なので、単純に掛ければ 200 KB 近くになります。
「sessionStorage はブラウザを閉じるまで残る」 — タブを閉じるまでです。同じオリジンを別タブで開いても値は共有されません。
「localStorage は期限がないので消えない」 — 消えます。期限の指定がないだけで、容量の逼迫や追跡防止の削除の対象です。
「トークンを sessionStorage に置けば localStorage より安全」 — XSS に対しては同じです。どちらも JavaScript から読めます。違うのは残る期間だけで、攻撃が成立する瞬間の防御力は変わりません。
確認問題
問 1. https://example.com で次のコードを実行しました。その後 http://example.com を開くと、localStorage.getItem('theme') は何を返しますか。
localStorage.setItem('theme', 'dark');
解答と解説
答え: null を返します。
Web Storage を分ける鍵はオリジンで、scheme を含みます。https://example.com と http://example.com は別のオリジンなので、別々の領域を持ちます。
同じ host なので、クッキーであれば Secure を付けない限り両方へ送られます。同じ「ブラウザに置く」でもクッキーと Web Storage で結果が逆になるのがこの章の主題です。
ローカル開発で http://localhost を使い、本番で https を使っている場合、この差が「本番だけ設定が引き継がれない」形で出ます。
問 2. ユーザーの下書きを保存する機能を作ります。1 件あたり 200 KB 程度で、最大 30 件です。localStorage を選んでよいですか。
200 KB × 30 件 = 約 6 MB
解答と解説
答え: 選べません。オリジンごとの上限 5 MiB を超えます。
localStorage の 5 MiB は領域全体にかかる上限です。1 キーあたりではないので、30 件の合計で判定します。約 6 MB は上限を超えるため、書き込みの途中で例外が投げられます。
さらに localStorage の API は同期なので、200 KB の文字列を読み書きするあいだメインスレッドが止まります。件数が上限内に収まっていても、この点だけで避ける理由になります。
この規模のデータは IndexedDB が引き受けます。非同期で、容量の制限も別の体系です。次の章で扱います。
問 3. ログイン後にアクセストークンを localStorage に保存する実装をレビューしています。「XSS 対策として CSP を入れているので問題ない」と説明されました。どう答えますか。
解答と解説
答え: CSP は XSS を減らしますが、ゼロにはしません。成立したときの被害の大きさが置き場で変わります。
OWASP は「Do not store authentication tokens, session IDs, JWTs, refresh tokens, or any credential in localStorage or sessionStorage.」と述べています。この指針は CSP の有無を前提にしていません。
比較するのは「XSS が起きる確率」ではなく「起きたときに何が持ち出されるか」です。
| 置き場 | XSS が成立したとき |
|---|---|
localStorage | 値そのものを読み出して外部へ送れる |
HttpOnly のクッキー | 値は読めない。ただしそのクッキーが付いたリクエストは送れる |
どちらも被害は出ますが、値を持ち出されると攻撃者の手元でトークンを再利用できます。ブラウザを閉じた後も、別の端末からも使えます。HttpOnly なら攻撃はそのページのセッション中に閉じます。
短命なアクセストークンをメモリに持ち、リフレッシュトークンを HttpOnly のクッキーに置く構成が、この差を踏まえた設計です。
まとめ
- 3 つの機構は「サーバーへ届くか」「何で分かれるか」「いつまで残るか」「容量の制限がどうかかるか」「タブ間で共有されるか」で選び分ける
- クッキーと Web Storage は分かれる単位が正反対。クッキーは scheme と port を見ず、Web Storage はオリジンなので両方を見る
- 容量の数値は向きが逆。クッキーの 4096 バイトは 1 個あたりの下限、Web Storage の 5 MiB は領域全体の上限
sessionStorageはタブごとに独立する。別タブへは伝わらない- 期限を持たないことは消えないことではない
HttpOnlyは保存先ではなくクッキーの属性で、塞ぐのは読み取りだけ- 資格情報は Web Storage に置かない。短命なトークンはメモリ、長命なものは
HttpOnlyのクッキー
- MDN — Window.localStorage
- MDN — Window.sessionStorage
- Storage Standard — storage key、保存先ごとの容量、
sessionStorageが消える単位を定める仕様 - WHATWG HTML — Web storage — Web Storage の API の定義
- RFC 6265 — HTTP State Management Mechanism — クッキーの下限を定める
- OWASP — Session Management Cheat Sheet
- React ガイド — SPA の認証 — トークンの保管とリフレッシュフローの実装
次に読む
- IndexedDB と Cache API — 5 MiB に収まらないデータと、保存したものが消える条件
- クッキー — 自動送出の条件と属性の意味
- Web アプリの主要な攻撃と対策 — XSS が成立したときに保存領域から何が持ち出されるか