なぜ毎回「どこに保存するか」で迷うのか
「ログイン状態を保持したい」「ダークモードの設定を覚えておきたい」「入力途中のフォームを復元したい」—— こうした要件が出てきたとき、ブラウザ側の保存先には Cookie・localStorage・sessionStorage・IndexedDB・Cache API・OPFS と選択肢が並びます。 そして多くの場合、とりあえず localStorage に落ち着きます。
迷う原因は、これらが「容量が違うだけの同じもの」ではなく、そもそも設計目的が違う仕組みだからです。 比較すべき軸は容量ではなく、次の6つです。
- 寿命 — いつ消えるのか。タブを閉じたら / 期限が来たら / 明示的に消すまで
- スコープ — 誰が読めるのか。オリジン単位 / タブ単位 / サブドメイン込み
- サーバへの自動送信 — HTTPリクエストに勝手に乗るのか
- API の同期性 — メインスレッドをブロックするのか
- 容量 — 数KB か、数GB か
- XSS 耐性 — JavaScript から読み出せてしまうのか
この6軸で見ると、「認証トークンを localStorage に入れてよいか」「設定を Cookie に入れるべきか」といった判断が機械的に決まります。 まずは全体像を俯瞰しておきましょう。
graph TD
A[ブラウザに値を保存したい] --> B{サーバが毎回<br/>その値を必要とするか}
B -->|必要| C[Cookie<br/>唯一自動送信される]
B -->|不要| D{リロードや<br/>タブを越えて残すか}
D -->|タブ内だけでよい| E[sessionStorage]
D -->|残したい| F{データの量と形}
F -->|小さなKey-Value| G[localStorage]
F -->|構造化・大量| H[IndexedDB]
F -->|HTTPレスポンス| I[Cache API]
F -->|ファイル・バイナリ| J[OPFS]
Cookie — サーバに届く唯一の保存先
Cookie の本質は「保存領域」ではなく「HTTPリクエストに自動で添付される小さなデータ」です。 他のストレージとの決定的な違いはここで、fetch や画像読み込み、ページ遷移を含むあらゆるリクエストに、 該当ドメイン・パスの Cookie が勝手に付いていきます。だからこそセッション管理に使われ、だからこそ CSRF の原因にもなります。
sequenceDiagram
participant B as ブラウザ
participant S as サーバ
B->>S: POST /login
S-->>B: Set-Cookie sid=abc<br/>HttpOnly / Secure / SameSite=Lax
Note over B: Cookie Jar に保存<br/>JS からは読めない
B->>S: GET /dashboard<br/>Cookie sid=abc
Note over S: セッションを解決して認可
S-->>B: HTML
B->>S: GET /api/me<br/>Cookie sid=abc
Note over B: fetch でも自動で付与される
属性でセキュリティ特性が決まる
Set-Cookie: __Host-sid=abc123;
HttpOnly;
Secure;
SameSite=Lax;
Path=/;
Max-Age=1209600| 属性 | 効果 | 実務での指針 |
|---|---|---|
| HttpOnly | JavaScript から読み書きできなくなる | ★最重要。セッションIDや認証トークンには必ず付ける |
| Secure | HTTPS 接続でのみ送信される | 常に付ける。SameSite=None では必須 |
| SameSite | クロスサイトリクエストへの添付を制御。Strict / Lax / None | デフォルトは Lax 扱い。None にするなら CSRF 対策を別途用意 |
| Domain | 省略時は発行ホストのみ。指定するとサブドメインにも送られる | 不要に広げない。広げた分だけ漏洩面が増える |
| Path | 送信対象のパスを限定 | 実質的なセキュリティ境界にはならないと考える |
| Max-Age / Expires | 有効期限。どちらも無ければセッションCookie | 長すぎる期限は盗用時の被害を長期化させる |
| Partitioned | トップレベルサイトごとに分離された3rd party Cookie | 埋め込みウィジェットの状態保持に使う。いわゆる CHIPS |
| __Host- / __Secure- プレフィックス | 属性の条件を満たさない Cookie をブラウザが拒否 | 設定ミスをブラウザ側で防げる安価な保険 |
メリットとデメリット
メリットは、サーバ側で値が使えること、そして HttpOnly によってJavaScript から完全に隔離できる唯一の保存先であることです。 XSS を踏んでも Cookie の中身そのものは読み出せません。
デメリットは3つあります。第一に容量が極端に小さいこと。RFC 6265 が保証を求める下限は 1 Cookie あたり 4096 バイト・1ドメインあたり50個で、実際のブラウザもこの水準です。 第二に、毎リクエストに乗るためオーバーヘッドになること。数KBの Cookie は全リクエストに数KBを足します。 第三に、クロスサイト文脈での扱いがブラウザごとに大きく違うことです。
localStorage / sessionStorage — 手軽さの裏側
Web Storage は setItem / getItem だけで使える最も気軽なAPIです。 localStorage と sessionStorage はAPIが完全に同一で、寿命とスコープだけが違います。
| localStorage | sessionStorage | |
|---|---|---|
| 寿命 | 明示的に削除するまで(ブラウザを閉じても残る) | そのタブを閉じるまで |
| スコープ | オリジン単位。同一オリジンの全タブで共有 | オリジン+タブ単位。別タブからは見えない |
| タブ複製時 | 当然共有される | 複製元の内容がコピーされる(以後は独立) |
| 向いている用途 | テーマ設定、直近の表示条件、下書き | 複数ステップのウィザード、遷移前URLの一時退避 |
メリット
同期APIなので await もトランザクションも不要で、数行で書けます。 オリジン単位で最大 5MiB 程度あり、Cookie と違ってサーバに送信されないため通信を太らせません。storage イベントを使えば、他タブでの変更を検知して UI を同期することもできます (自タブの変更では発火しない点に注意)。
デメリット
① 同期APIがメインスレッドをブロックする — これが最大の弱点です。大きな JSON を JSON.stringify して書き込む処理はディスクI/Oが終わるまでメインスレッドを止め、 入力応答性(INP)を悪化させます。「保存されているデータが増えるほど、体感が重くなる」タイプの不具合の温床です。
② 文字列しか保存できない — オブジェクトは必ず JSON.stringify / JSON.parse を通します。Date は文字列に、undefined はキーごと消え、Map は空オブジェクトになります。 さらに古いバージョンのスキーマが残ったまま JSON.parse の結果を無検証で使うと、実行時に壊れます。
③ XSS で全件読み出せる —localStorage は JavaScript から素通しで読めます。XSS が一箇所でも成立すれば、 保存されている全キーがまとめて外部に送信され得ます。ここが認証トークンを置いてはいけない理由です。
④ Web Worker から使えない — Web Storage は window にしか存在しないため、Worker や Service Worker からはアクセスできません。 バックグラウンド処理と状態を共有したいなら IndexedDB が必要です。
IndexedDB — 量と構造が必要になったら
IndexedDB は非同期のトランザクショナルなKey-Valueストアで、オブジェクトストア・インデックス・カーソルを備えています。 構造化クローン可能な値(オブジェクト、配列、Date、Blob、File、ArrayBuffer など)をシリアライズなしでそのまま保存でき、容量は環境次第で数百MB〜数十GBに達します。 Worker からも使えるため、オフライン対応や重いキャッシュの置き場所はここが正解です。
デメリットは生APIの書きにくさに集約されます。onupgradeneeded でのスキーマ移行、イベントベースのリクエスト、トランザクションの自動クローズなど、 素で書くとコード量が一気に増えます。実務では idb や idb-keyval のような薄いラッパを挟むのが定石です。
// idb-keyval を使えば localStorage 並みの手数で非同期ストレージが使える
import { get, set, del } from 'idb-keyval';
// オブジェクトや Blob をそのまま保存できる(JSON 変換不要)
await set('draft:article-42', {
title: '下書き',
updatedAt: new Date(), // Date のまま復元される
cover: coverBlob, // Blob もそのまま入る
});
const draft = await get('draft:article-42');
await del('draft:article-42');その他の保存先 — 目的が尖った選択肢
| 保存先 | 何を保存するものか | 使いどころと注意点 |
|---|---|---|
| Cache API | Request / Response のペア | Service Worker と組んでオフライン対応。アプリの状態ではなくネットワーク応答を保存する仕組み |
| OPFS(Origin Private File System) | オリジン専有のファイル | WASM 版 SQLite の保存先など高速I/Oが要る用途。ユーザーのファイルシステムからは見えない |
| Storage Buckets API | 上記ストレージを束ねる「バケット」 | バケット単位で退避優先度や永続性を指定できる。Chrome 122+ のみで Firefox / Safari 未対応のためフォールバック必須 |
| CookieStore API | Cookie(非同期API) | document.cookie の文字列パースから解放され、Service Worker からも使える。Chromium 系のみ |
| history.state | 履歴エントリに紐づく状態 | 「戻る」で復元したいスクロール位置や一覧の条件に最適。サイズ上限があり大きな値は入らない |
| URL のクエリ / ハッシュ | 共有・復元したい状態 | 検索条件やタブ選択はここが第一候補。共有・ブックマーク・リロードすべてに強い |
| メモリ(JS変数・状態管理) | 揮発してよいすべて | 最速かつ最も安全。リロードで消えて困らない値は、そもそも保存しないのが最善手 |
6軸で並べた一覧表
| 比較軸 | Cookie | localStorage | sessionStorage | IndexedDB |
|---|---|---|---|---|
| 容量目安 | 約4KB / 1個、1ドメイン50個程度 | 約5MiB / オリジン | 約5MiB / オリジン | 数百MB〜数十GB(quota依存) |
| 寿命 | Max-Age / Expires で指定 | 明示削除まで | タブを閉じるまで | 明示削除まで |
| スコープ | ドメイン+パス(サブドメイン共有可) | オリジン | オリジン+タブ | オリジン |
| サーバへの自動送信 | される(毎リクエスト) | されない | されない | されない |
| API | 同期・文字列パース必要 | 同期(ブロックする) | 同期(ブロックする) | 非同期・トランザクション |
| 保存できる型 | 文字列のみ | 文字列のみ | 文字列のみ | 構造化クローン可能な値・Blob |
| JS からの隔離 | HttpOnly で可能 | 不可(XSSで全件読める) | 不可 | 不可 |
| Worker から利用 | 不可(CookieStore なら可) | 不可 | 不可 | 可能 |
| 代表的な用途 | セッション、認証、CSRFトークン | テーマ・UI設定・下書き | ウィザードの一時状態 | オフラインデータ、大容量キャッシュ |
実務の分岐点 — 認証トークンをどこに置くか
この議論は「localStorage か Cookie か」の二択で語られがちですが、実際には3つ目の選択肢があります。
| 方式 | XSS で盗まれるか | CSRF 対策 | 評価 |
|---|---|---|---|
| localStorage にトークンを保存 | 盗まれる(JS から読める) | 不要(自動送信されない) | 手軽だが、XSS 一発で長期トークンが漏れる。避けたい |
HttpOnly Cookie | 読み出せない | 必要(SameSite + CSRFトークン) | 定番。サーバ側セッションと相性がよい |
アクセストークンはメモリのみ+リフレッシュは HttpOnly Cookie | 漏洩範囲が短命なトークンに限定される | 必要(更新エンドポイントのみ) | SPA の推奨形。実装は増えるが被害を最小化できる |
ポイントは、HttpOnly Cookie にしてもXSS が無害になるわけではないことです。 トークンは読めなくとも、攻撃者は被害者のブラウザ上から任意のリクエストを送れます(Cookie は自動で付く)。 それでも「トークンそのものを持ち出されて別環境から長期間使われる」ことは防げるため、被害の質が大きく変わります。 そのうえで CSP による XSS 抑止と組み合わせるのが本筋です。
「保存したのに消える」を前提に設計する
ブラウザストレージは、どれも永続を保証しません。消える経路は主に4つあります。
- ユーザーによる削除 — 閲覧履歴の消去やサイトデータのクリア
- 容量圧迫による自動退避 — デバイスの空きが減ると、最終アクセスが古いオリジンから LRU で丸ごと削除される(部分削除ではない)
- WebKit の7日ルール — 前述のとおり、操作がないサイトのスクリプト書き込みデータが消える
- プライベートブラウジング — セッション終了で破棄、環境によっては書き込み自体が失敗する
容量と残量は navigator.storage.estimate() で概算が取れます。 退避を避けたいデータには navigator.storage.persist() で永続化を要求できますが、これは「お願い」であって保証ではありません(ブラウザやユーザー履歴により拒否されます)。
// 残量の確認と、退避されにくい状態への昇格リクエスト
const { usage = 0, quota = 0 } = await navigator.storage.estimate();
console.log(`使用量 ${Math.round(usage / 1024 / 1024)}MB / 上限 ${Math.round(quota / 1024 / 1024)}MB`);
// true が返っても「消えない保証」ではない点に注意
const persisted = await navigator.storage.persist();
if (!persisted) {
// 消える前提でサーバ同期のフォールバックを用意する
}参考として、代表的な上限は次のとおりです(実測値ではなく仕様上の目安)。 Chromium 系はオリジンあたりディスクの約60%・ブラウザ全体で約80%、 Firefox は best-effort でディスクの10%または10GiBの小さい方、 WebKit はブラウザアプリで約60%・組み込み WebView では約15%と、桁が違います。「PCで動いたから iOS の WebView でも動く」とは言えません。
ハマりどころと対処
実務では、Web Storage を直接呼ばず「失敗しても落ちない」ラッパを1枚挟むのが結局いちばん安く済みます。 プライベートブラウジングでの例外、容量超過、壊れた JSON、旧スキーマをまとめてここで吸収します。
type Stored<T> = { v: number; data: T };
// v(スキーマバージョン)で旧データを弾き、失敗は常に既定値へフォールバックする
export function loadJSON<T>(key: string, version: number, fallback: T): T {
try {
const raw = localStorage.getItem(key);
if (!raw) return fallback;
const parsed = JSON.parse(raw) as Stored<T>;
if (parsed.v !== version) return fallback; // 旧スキーマは捨てる
return parsed.data;
} catch {
// JSON 破損、プライベートモードでのアクセス拒否など
return fallback;
}
}
export function saveJSON<T>(key: string, version: number, data: T): boolean {
try {
localStorage.setItem(key, JSON.stringify({ v: version, data }));
return true;
} catch (e) {
// QuotaExceededError / SecurityError は「保存できなかった」だけで済ませる
if (e instanceof DOMException) return false;
throw e;
}
}そのほか、押さえておきたい落とし穴を挙げます。
- 複数タブの不整合 — 片方のタブで設定を変えても、もう片方は古い値を持ち続けます。
storageイベントかBroadcastChannelで同期させます - ログアウト時の掃除漏れ — Cookie を消してもストレージ側に前ユーザーの下書きやキャッシュが残ります。ログアウト処理で明示的に削除するか、キーにユーザーIDを含めます
- 個人情報の保存 — 端末共有時や XSS 時のリスクがそのまま漏洩範囲になります。加えて日本の外部送信規律や GDPR では、Cookie だけでなく localStorage などの端末情報も規制対象になり得ます
- キーの命名衝突 — 同一オリジンに複数アプリを置くと衝突します。
appname:feature:keyのように接頭辞で名前空間を切ります
選び方のフローチャート
flowchart TD
S[保存したい値がある] --> Q0{リロードで<br/>作り直せるか}
Q0 -->|作り直せる| M[保存しない<br/>メモリに置く]
Q0 -->|作り直せない| Q1{URL に載せて<br/>共有したいか}
Q1 -->|載せたい| U[クエリ / ハッシュ]
Q1 -->|載せない| Q2{サーバが毎回<br/>必要とするか}
Q2 -->|必要| Q3{JS から<br/>読ませたいか}
Q3 -->|読ませない| C1[HttpOnly Cookie]
Q3 -->|読ませる| C2[通常の Cookie<br/>ただし量は最小に]
Q2 -->|不要| Q4{そのタブ限りか}
Q4 -->|タブ限り| SS[sessionStorage]
Q4 -->|残す| Q5{5MB を超える<br/>または Worker で使うか}
Q5 -->|はい| IDB[IndexedDB]
Q5 -->|いいえ| LS[localStorage]
まとめ
ブラウザの保存先選びは、容量表を眺めるより3つの質問で決めるのが速いです。 「サーバが毎回その値を必要とするか」——必要なら Cookie。 「消えていいか」——タブ限りなら sessionStorage、リロードで作り直せるならそもそも保存しない。 「XSS で読まれて困るか」——困るなら HttpOnly Cookie かメモリで、localStorage は選ばない。
そして、どの選択肢を選んでもデータは消える前提で書くことが最後の要点です。 Safari の7日ルール、容量圧迫時の LRU 退避、ユーザーによる削除——これらは避けられません。 失敗しても既定値に落ちるラッパを1枚用意し、失われて困るデータはサーバに置く。 この二点を守るだけで、ストレージ起因の不具合はほとんど姿を消します。
参考リンク
- Storage quotas and eviction criteria — MDN(本記事の容量目安・退避ルールの出典。Web Storage は localStorage / sessionStorage 各5MiB、Chromium はディスクの約60%、Firefox は best-effort で10%または10GiB、WebKit はブラウザで約60%・WebView で約15%)
- Cookies Having Independent Partitioned State (CHIPS) — MDN(
Partitioned属性の仕様と、トップレベルサイトによる二重キー化の説明) - Set-Cookie — MDN(
HttpOnly/Secure/SameSite/__Host-プレフィックスの正確な挙動) - Full Third-Party Cookie Blocking and More — WebKit Blog(スクリプト書き込みストレージの7日上限に関する一次情報)
- Not all storage is created equal: introducing Storage Buckets — Chrome for Developers(バケット単位での永続性・退避優先度の指定)
- Cookie Store API — MDN(非同期 Cookie API と Service Worker からの利用)
- Content Security Policy 入門 — 本ブログ(本記事で触れた「XSS を前提にした保存先選び」の、もう一方の対策側)
理解度チェック
Cookie が localStorage や IndexedDB と決定的に異なる点はどれですか?
キーボード: 1〜4 で選択、Enter で回答