localStorage と sessionStorage と IndexedDB と Cookie — ブラウザストレージの3時までの後悔なし
適切なブラウザストレージを選ぶための実用ガイド — 比較表、JWTに関する議論が明確に説明され、各選択肢があなたの夜を破壊する具体的な方法が示されている。
It’s 3am 😬. A user filed a bug: their shopping cart is empty. You open DevTools, click the Application tab, and stare at the sidebar. どこに置いたのですか? 正しい答えは、開発者がバグが提出された後に初めて考えるいくつかの質問に依存します。
これは定義に関する記事ではありません。そのようなものはあらゆる場所にあります。これは、何が破綻するか、実際にどう使うべきか、そしてJWTをローカルストレージに保存する議論が本質を欠いている理由について話す部分です。
短い要約(つまり、ブックマークできる表)
| 認証トークンには避けるべき | localStorageと同じリスク | IndexedDB | クッキー | |
|---|---|---|---|---|
| 永続性 | 永久的 | タブセッションのみ | 永久的 | 設定可能(セッションまたは有効期限日付) |
| 最大サイズ | 5–10 MB | 5–10 MB | GB(ブラウザクォータ) | ~4 KBごとに1つのクッキー |
| サーバーへのアクセス | いいえ | いいえ | いいえ | はい — すべてのリクエストとともに送信されます |
| 非同期API | いいえ(メインスレッドをブロッキング) | いいえ(メインスレッドをブロッキング) | はい(Promiseまたはイベントベース) | いいえ |
| JavaScriptで読み取り可能 | はい | はい | はい | HttpOnlyフラグがない場合のみ |
| Web Workerへのアクセス | いいえ | いいえ | はい | いいえ |
| タブ間で共有 | はい | いいえ — 各タブは孤立しています | はい | はい |
| Safari ITPの除外 | 7日間の相互作用がない場合 | タブを閉じたとき | 7日間の相互作用がない場合 | Expires属性に依存 |
認証トークンには避けるべき
永続的、同期的、オリジンに限定された。誰もが使うが、半分は使わるべきでないワークホース。
実際に何であるか
localStorageは文字列キー-値ペアを保存します。それだけです。保存制限はほとんどのブラウザで5MB、一部では10MB(Chromeではさらに多く与えられます)。オリジン(プロトコル+ドメイン+ポート)に限定されており、 http://example.com と https://example.com 別々のストレージを持ちます。タブを閉じたり、ブラウザを再起動しても生き残り、ユーザーがブラウザデータをクリアしたり、明示的に localStorage.clear().
// Read/write is synchronous — it happens right now, on the main thread
localStorage.setItem('theme', 'dark');
const theme = localStorage.getItem('theme'); // 'dark'
// Storing objects? You're serializing manually.
localStorage.setItem('user', JSON.stringify({ id: 1, name: 'Alex' }));
const user = JSON.parse(localStorage.getItem('user'));
どこで破綻するか
- クォータ超過 — 同期的に
DOMException: QuotaExceededErrorを投げます - プライベート/インコインサイト — ブラウザは新しい、孤立したlocalStorageを提供し、クォータを厳しく(またはゼロ)設定します。Firefoxは過去に即座にクォータエラーを投げました。localStorageが利用可能であることを常に確認する必要があります。
- Safari ITP — ユーザーが7日間サイトに訪問していない場合、SafariはlocalStorageをクリアする可能性があります。これはドキュメントされた振る舞いです。最も悪いタイミングで驚かされます。
- XSS — localStorage内のすべての内容は、あなたのオリジン上で実行されているJavaScriptによって読み取られます。攻撃者がスクリプトを注入できる場合、すべてのデータを取得できます。
使用する場合
UI設定(ダークモード、サイドバーの状態、言語)、非敏感なキャッシュ(最後に取得されたタイムスタンプ、静的設定)、ページリフレッシュ後に生き残るべきが、認証トークンやPIIを含まないもの。JWTやAPIキーをここに保存する考えがある場合は、続きを読みましょう。
localStorageと同じリスク
localStorageが行うすべてのことを、短い寿命と、人々が常に直面する重要な隔离行動を備えています。
タブ隔离の罠
sessionStorageはタブごとに、ブラウザ全体ではなくです。同じページを開くと、別のタブに完全に分離されたsessionStorageが得られます。これは ない ユーザーにとって明らかであり、あなたにとって明らかになるのは、ユーザーが「誤って」2つのタブを開いたときに、多段階フォームのデータが消えるというバグが提出されたときです。
1つの例外:ユーザーが window.open() またはリンクをマウスの中央クリックで開く場合、新しいタブは開設時における親のsessionStorageのコピーを取得します。それ以降、2つのタブは分離されます。これは、2時頃のStack Overflowに非常に良い質問を生むようなエッジケースです。 コピー の時刻に、親のsessionStorageのコピーを取得します。それ以降、2つのタブは分離されます。これは、2時頃のStack Overflowに非常に良い質問を生むようなエッジケースです。
// Perfect for checkout flows — step data lives until the tab closes
sessionStorage.setItem('checkoutStep', '2');
sessionStorage.setItem('cartSnapshot', JSON.stringify(cart));
// Cleared automatically when the tab closes — no cleanup code needed
使用する場合
多段階フォームの状態、一度使用するウィザードデータ、1つのセッションで存在し、タブを閉じたときに消えるもの。チェックアウトフローにおいては、部分的なチェックアウト状態が前セッションから残るのを防ぐために実際に役立ちます。タブや独立したページ間でデータを共有する必要がある場合は、使用しないでください。
IndexedDB
成長した選択肢。非同期、トランザクション可能で、実際のJavaScriptオブジェクトを保存できる。また、3つのうち最も不快なネイティブAPIを持ち、そのためほとんど誰も直接使用しません。
実際に何であるか
IndexedDBは完全なキー-値ストレージで、インデックスとカーソルベースのクエリをサポートしています。ストレージ制限は豊かです — ブラウザはディスクスペースのパーセンテージを許容し、実際にはギガバイトを許容します。構造化オブジェクト、Blobs、ArrayBuffers、Filesを保存でき、手動シリアル化を必要としません。Web Workerで利用可能です。PWAやオフライン対応アプリが、メモリに保持するべきデータを保存するために使用します。
// Native IDB API — nobody writes this directly in production
const request = indexedDB.open('myDB', 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
db.createObjectStore('users', { keyPath: 'id' });
};
request.onsuccess = (event) => {
const db = event.target.result;
const tx = db.transaction('users', 'readwrite');
const store = tx.objectStore('users');
store.put({ id: 1, name: 'Alex', avatar: someBlob });
};
// What you actually use — Dexie.js or the idb wrapper
import Dexie from 'dexie';
const db = new Dexie('myApp');
db.version(1).stores({ users: '++id, name, email' });
await db.users.add({ name: 'Alex', email: 'alex@example.com' });
どこで破綻するか
- Safari ITP — localStorageと同じように:7日間の相互作用がないと、SafariはIndexedDBデータを除外します。これは、AppleがiOSの新しいバージョンで修正する前にいくつかのPWAを殺しました。iOS Safariユーザーをターゲットにしている場合は、これを考慮してください。
- iOSの低ストレージ — iOSでは、OSがストレージが低い場合にIndexedDBデータを削除します。それは尋ねません。データが存在しなくなります。
- プライベートブラウジング — ChromeはインコインサイトでIndexedDBを許可(セッションごとのクォータ)。Safariのプライベートブラウジングでは過去にエラーを投げていました。バージョンによって振る舞いが異なります。
- ネイティブAPI — ワッパーなしで原始的なIDBコードを書くと、コールバックベースのイベントモデルが、あなたが1日中デバッグするようなバグを生み出します。を使用してください idb または Dexie.js.
使用する場合
オフラインアプリ、大量データセット(localStorageを過剰に使用する場合)、localStorageに保存するデータが5MBの上限に達する場合、PWAでのファイルキャッシュ。50件のユーザー生成ドキュメントをローカルに保存する場合は、IndexedDBが答えです。ユーザーのテーマ設定を保存する場合は、過剰です。
クッキー
4つのうち最も古いもの。サーバーが見ることができる唯一のもの。4KBの制限があり、JWTを1つに保存しようとすると必ずビットを食い取る。
クッキーが異なる点
クッキーは自動的にすべてのマッチするHTTPリクエストとともに送られます。これは機能であり、問題でもあります。つまり、セッションクッキーはJavaScriptなしでサーバーに到達し、同時にすべてのリクエストにクッキーのオーバーヘッドが含まれます。 api.example.com は、クッキーのオーバーヘッドを運びます。
実際に重要な属性:
- HttpOnly — JavaScriptはこのクッキーを読み取れません。XSS攻撃はこれを外泄できません。これはセッションクッキーの基本的な要求です。
- 安全な — HTTPSでのみ送信されます。プロダクションサイトでこの設定がない場合、認証クッキーがHTTPで送信されます。しないでください。
- SameSite=Strict — クッキーは、リクエストが自社ドメインから発生した場合にのみ送信されます。CSRF保護が有効ですが、OAuthリダイレクトフローを破壊します。SameSite=Laxはほとんどのアプリにとって適切な妥協です。
- Expires / Max-Age — これらがない場合、それはセッションクッキーであり、ブラウザが閉じられたときにクリアされます。"Remember me"の動作のために明示的な有効期限を設定してください。
// Setting a cookie from JavaScript (no HttpOnly, obviously)
document.cookie = "theme=dark; Path=/; Max-Age=31536000; Secure; SameSite=Lax";
// Server-side (Node.js + Express) — where the real power is
res.cookie('sessionId', token, {
httpOnly: true, // JS cannot read this
secure: true, // HTTPS only
sameSite: 'lax', // balanced CSRF protection
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days in ms
});
XSSの問題をデバッグしている場合、 IO Tools’ Cookie Parser を入力して、読み取り可能なキー-値ペアに分解します — 400文字の Set-Cookie ヘッダーを眺めながら、どの属性が間違っているかを確認するときに役立ちます。
どこで破綻するか
- 4KB制限 — 名前+値+すべての属性を含む総サイズです。典型的なJWTは400–800バイトで、多くのクレームを持つ大きなものは1–2KBです。あなたにはあまりスペースがありません。
- SameSite=StrictはOAuthを殺す — あなたのIdPがログイン後にアプリにリダイレクトするとき、それはクロスサイトリクエストです。SameSite=Strictにより、セッションクッキーが送信されません。OAuthフローのすべてに対してLaxを使用してください。
- クッキーの順序とパスマッチング — 重複するパスを持つ複数のクッキーがある場合、ブラウザはそれらを不定の順序で送信します。優先順位に頼らないでください。
- 第三パーティクッキーの廃止 — Chromeは第三パーティクッキーを削除しています。クロスサイトクッキーディペンダントは中長期的なリスクです。
JWTをローカルストレージに保存する議論(実際の攻撃面)
これはすべての認証ディスカッションで登場し、多くの議論をしている人々はお互いに話していることのない話をしてしまっています。
JWTをlocalStorageに保存する懸念:あなたのサイトにXSSの脆弱性がある場合、攻撃者のスクリプトはトークンを直接読み取り、それを外泄できます。攻撃者は、有効な認証トークンを持ち、どこからでも、どのデバイスでも使用でき、有効期限までそのトークンを保持できます。
HttpOnlyクッキーへの代替案:JavaScriptはこのクッキーを読み取れず、XSSによる外泄はできません。セッションはリクエストで依然として利用可能(攻撃者は被害者のブラウザからリクエストを実行できます)ですが、トークンを他の場所で使用することはできません。これは影響範囲を縮小します。
本物の見解: 根本的な問題はXSSであり、ストレージの場所ではありません。XSSがある場合、HttpOnlyクッキーは意味的に優れています — 攻撃者はトークンをオフサイトに持ち出せません。しかし、XSSを解決することは、コンテンツセキュリティポリシーを厳しく設定し、信頼できない第三パーティスクリプトを排除し、適切な出力エスケープを行うことで達成可能なより意味のある目標です。
SPAを構築していて、厳密なCSPと制御できない第三パーティJSがない場合、localStorageでJWTはおそらく問題ありません。Google Tag Manager、広告ピクセル、数十個のnpm依存関係を持つサイトの場合、HttpOnlyクッキーはより安全です — あなたのXSS攻撃面はあなたが思うより大きいです。
JWTの問題をデバッグする際、 JWT Decoder on IO Tools を貼り付けて、パラメータと有効期限を確認できます。コードを書かず、トークンが有効かどうかを確認するときに役立ちます。 が正確なタイムスタンプと残り時間を教えてくれます。 は、401の連鎖を追跡するときに、トークンがまだ有効かどうかを確認するのに便利です。
決定フロー図
3時頃にStack Overflowを開く前に、このフローを経てください:
- サーバーがそれを読み取る必要があるか? → クッキー。議論は終わりです。
- 5MBを超えていて、クエリ可能が必要か? → IndexedDB。
- タブを閉じたときに消えるべきか? → sessionStorage。
- その他すべて → localStorage、保存する内容について適切な注意を払う。
みんなが間違えること
ストレージAPIは、すべてのユーザーすべての時間に信頼性を持ちません。ブラウザ、OS、プライバシー設定、またはユーザーによってクリアされます。クライアント側ストレージを真実のソースとして扱うアーキテクチャは、最終的に誰かに失敗します。
持つパターン:ブラウザストレージを実際の真実のソース(サーバー)のパフォーマンス最適化として扱い、積極的にキャッシュするが、キャッシュが空の場合にアプリが柔軟に回復できるように設計する。3時頃のデバッグセッションは、使ったストレージAPIについてのものではなく、データが存在しなかったときにそのデータが存在するという前提をもっていることについてのものである。
恵 スコアボードが到着しました!
スコアボード ゲームを追跡する楽しい方法です。すべてのデータはブラウザに保存されます。さらに多くの機能がまもなく登場します!
