なぜ毎回「どこに保存するか」で迷うのか

「ログイン状態を保持したい」「ダークモードの設定を覚えておきたい」「入力途中のフォームを復元したい」—— こうした要件が出てきたとき、ブラウザ側の保存先には 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 の本質は「保存領域」ではなく「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
属性効果実務での指針
HttpOnlyJavaScript から読み書きできなくなる★最重要。セッションIDや認証トークンには必ず付ける
SecureHTTPS 接続でのみ送信される常に付ける。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が完全に同一で、寿命とスコープだけが違います

localStoragesessionStorage
寿命明示的に削除するまで(ブラウザを閉じても残る)そのタブを閉じるまで
スコープオリジン単位。同一オリジンの全タブで共有オリジン+タブ単位。別タブからは見えない
タブ複製時当然共有される複製元の内容がコピーされる(以後は独立)
向いている用途テーマ設定、直近の表示条件、下書き複数ステップのウィザード、遷移前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ストアで、オブジェクトストア・インデックス・カーソルを備えています。 構造化クローン可能な値(オブジェクト、配列、DateBlobFileArrayBuffer など)をシリアライズなしでそのまま保存でき、容量は環境次第で数百MB〜数十GBに達します。 Worker からも使えるため、オフライン対応や重いキャッシュの置き場所はここが正解です。

デメリットは生APIの書きにくさに集約されます。onupgradeneeded でのスキーマ移行、イベントベースのリクエスト、トランザクションの自動クローズなど、 素で書くとコード量が一気に増えます。実務では idbidb-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 APIRequest / Response のペアService Worker と組んでオフライン対応。アプリの状態ではなくネットワーク応答を保存する仕組み
OPFS(Origin Private File System)オリジン専有のファイルWASM 版 SQLite の保存先など高速I/Oが要る用途。ユーザーのファイルシステムからは見えない
Storage Buckets API上記ストレージを束ねる「バケット」バケット単位で退避優先度や永続性を指定できる。Chrome 122+ のみで Firefox / Safari 未対応のためフォールバック必須
CookieStore APICookie(非同期API)document.cookie の文字列パースから解放され、Service Worker からも使える。Chromium 系のみ
history.state履歴エントリに紐づく状態「戻る」で復元したいスクロール位置や一覧の条件に最適。サイズ上限があり大きな値は入らない
URL のクエリ / ハッシュ共有・復元したい状態検索条件やタブ選択はここが第一候補。共有・ブックマーク・リロードすべてに強い
メモリ(JS変数・状態管理)揮発してよいすべて最速かつ最も安全。リロードで消えて困らない値は、そもそも保存しないのが最善手

6軸で並べた一覧表

比較軸CookielocalStoragesessionStorageIndexedDB
容量目安約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つあります。

  1. ユーザーによる削除 — 閲覧履歴の消去やサイトデータのクリア
  2. 容量圧迫による自動退避 — デバイスの空きが減ると、最終アクセスが古いオリジンから LRU で丸ごと削除される(部分削除ではない)
  3. WebKit の7日ルール — 前述のとおり、操作がないサイトのスクリプト書き込みデータが消える
  4. プライベートブラウジング — セッション終了で破棄、環境によっては書き込み自体が失敗する

容量と残量は 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枚用意し、失われて困るデータはサーバに置く。 この二点を守るだけで、ストレージ起因の不具合はほとんど姿を消します。

参考リンク

理解度チェック

問題 0 / 50%
Q1

Cookie が localStorage や IndexedDB と決定的に異なる点はどれですか?

キーボード: 1〜4 で選択、Enter で回答