Chapter 12
Browser Storage, Cookies and Sessions
localStorage, sessionStorage, IndexedDB, cookies and session security.
12. Browser Storage, Cookies, and Sessions
Browser storage allows a web application to remember information between interactions, tabs, or visits. It is tempting to treat every storage mechanism as a convenient key/value dictionary, but each has different lifetime, capacity, privacy, and security properties. Cookies are sent with HTTP requests under defined rules; localStorage and sessionStorage are synchronous string stores; IndexedDB is an asynchronous database; and offline applications may need a durable queue that survives a temporary loss of connectivity.
This chapter compares those mechanisms and explains cookie flags, storage failures, authentication trade-offs, and a practical offline synchronization design. Client-side storage is controlled by the user and browser. Never treat it as a trusted source of authorization or as a substitute for server validation.
Overview and Learning Objectives
By the end of this chapter, you will be able to:
- Choose between local storage, session storage, cookies, and IndexedDB.
- Handle quotas, blocked storage, malformed values, and user privacy choices.
- Explain cookie scope, expiration,
HttpOnly,Secure, andSameSite. - Evaluate JWT storage trade-offs involving XSS and CSRF.
- Model offline writes as a queue that can be retried and reconciled safely.
- Inspect browser storage and debug persistence behavior in developer tools.
Choosing a Browser Storage Mechanism
Before persisting a value, ask: Does it need to survive reload? Does it belong to one tab or the whole origin? Should the browser send it to a server automatically? Is it sensitive? How much data is expected? Does the UI need to query by fields? A short-lived visual preference and a large offline document collection are different problems.
| Mechanism | Data model | Typical lifetime and scope | Sent automatically with requests? |
|---|---|---|---|
localStorage |
String key/value | Persists for the origin until cleared or removed | No |
sessionStorage |
String key/value | Current origin and top-level browsing context; normally ends with the tab session | No |
| IndexedDB | Asynchronous object database with stores and indexes | Persists until cleared, evicted, or removed by the application | No |
| Cookie | Small name/value plus attributes | Session or configured expiration; domain/path rules | Yes, when request matching rules permit |
Storage is partitioned and scoped by browser policy; same-origin restrictions apply, and third-party contexts can be further restricted. A user can clear data, use private browsing, disable storage, or configure eviction. Persistence is a feature, not a guarantee.
localStorage and sessionStorage
The Web Storage API stores string key/value pairs. Objects must be serialized as JSON; reading them returns strings, and a missing key returns null:
const preferences = {
theme: "dark",
compactNavigation: true,
};
localStorage.setItem("site-preferences", JSON.stringify(preferences));
const raw = localStorage.getItem("site-preferences");
if (raw !== null) {
try {
const saved = JSON.parse(raw);
if (
saved &&
typeof saved === "object" &&
["light", "dark", "system"].includes(saved.theme)
) {
applyTheme(saved.theme);
}
} catch {
localStorage.removeItem("site-preferences");
}
}Storage can throw, for example when the browser blocks persistence or the quota is exceeded. Access itself can throw in some environments, so a try block should cover property access and operations:
function readPreference(key) {
try {
const value = window.localStorage.getItem(key);
return value === null ? null : value;
} catch {
return null;
}
}
function savePreference(key, value) {
try {
window.localStorage.setItem(key, value);
return true;
} catch (error) {
if (error instanceof DOMException &&
(error.name === "QuotaExceededError" ||
error.name === "NS_ERROR_DOM_QUOTA_REACHED")) {
console.warn("Storage quota reached.");
}
return false;
}
}Web Storage APIs are synchronous. A large serialization or repeated reads can block the main thread, so they are suitable for small settings, not large collections or high-frequency writes. Do not store passwords, secret API keys, or other data whose confidentiality depends on JavaScript being unable to read it. Any script executing in the origin can access these stores.
localStorage is shared by same-origin documents and typically remains after the browser is closed. sessionStorage is scoped to a page session and a particular tab; a newly opened tab usually has a separate store. It can be useful for temporary per-tab state, but its exact behavior across duplicated tabs and session restoration can vary. Neither store is automatically synchronized to a server.
The storage event can notify other documents of a storage change. It fires in other same-origin browsing contexts, not in the document that performed the write:
window.addEventListener("storage", (event) => {
if (event.key === "site-preferences") {
refreshPreferences(event.newValue);
}
});Do not use localStorage as a locking mechanism or high-throughput cross-tab database. Simultaneous updates can overwrite one another. For complex coordination use a suitable messaging or database API and design for conflicts.
IndexedDB for Structured, Larger Data
IndexedDB is an asynchronous, transactional object database built into browsers. It stores structured-cloneable values, supports object stores and indexes, and does not block JavaScript while performing disk-backed work in the same way synchronous Web Storage can. It is appropriate for larger offline datasets, cached records, and data that must be queried by indexed fields.
A database has a name and version. Opening a new version triggers an upgrade event where stores and indexes are created. A minimal helper can wrap request callbacks in promises:
function openNotesDatabase() {
return new Promise((resolve, reject) => {
const request = indexedDB.open("fieldguide-notes", 1);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains("notes")) {
const store = db.createObjectStore("notes", { keyPath: "id" });
store.createIndex("by-updated", "updatedAt");
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
request.onblocked = () => {
console.warn("Database upgrade is blocked by another open tab.");
};
});
}Database upgrades can be blocked when another tab still holds an old connection. Applications should close connections on versionchange and explain when a refresh is needed:
const db = await openNotesDatabase();
db.onversionchange = () => {
db.close();
showMessage("Please reload to use the updated offline data.");
};Transactions define the scope of database work. A readwrite transaction can add, update, or delete records:
function saveNote(db, note) {
return new Promise((resolve, reject) => {
const transaction = db.transaction("notes", "readwrite");
transaction.objectStore("notes").put(note);
transaction.oncomplete = () => resolve(note);
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error ?? new Error("Transaction aborted"));
});
}The transaction, not just an individual request, determines whether the set of changes completed. A transaction may abort because of an error or quota failure. Keep transaction work focused and avoid awaiting unrelated network operations inside a transaction: transactions can become inactive when control returns to the event loop, and holding them open can block other work.
IndexedDB stores structured-cloneable data, not arbitrary live objects such as DOM nodes or functions. Design schema migrations deliberately; when a version changes, create or migrate stores in onupgradeneeded. Avoid opening many database connections without closing them. For substantial applications, a well-maintained wrapper library can improve ergonomics, but the underlying transactions and schema rules still matter.
Quotas, Eviction, and Blocked Storage
There is no universal storage quota that every browser grants to every site. Available space depends on browser, device, origin, and user conditions. Storage may be best-effort and can be evicted under pressure. Quota errors can occur during writes, not only during initialization. A site can inspect estimates with navigator.storage.estimate() where supported, but estimates are informational:
async function inspectStorage() {
if (!navigator.storage?.estimate) return null;
try {
const { usage, quota } = await navigator.storage.estimate();
return { usage, quota };
} catch {
return null;
}
}navigator.storage.persist() can request persistent storage in supporting browsers, but the browser decides whether to grant it. It is not a way to override user choice or guarantee unlimited space. Treat all reads as potentially absent, stale, malformed, or unavailable. Keep a server-side source of truth for important user data, and provide graceful behavior when persistence fails.
When approaching a quota, remove disposable caches before user-created content, and expose a clear recovery path. Do not clear unrelated data automatically. For private or sensitive information, include an understandable deletion control and honor the application's retention policy.
Cookies: Request State with Attributes
Cookies are small name/value pairs associated with a host and path and subject to expiration and browser policy. Matching cookies are included in HTTP requests automatically, which makes them useful for server-managed sessions. This automatic sending is also relevant to cross-site request forgery defenses. Cookies are not a general storage replacement for large client-side data.
A cookie can be created from JavaScript with document.cookie, but that interface is a string and has important limits. The HttpOnly attribute can only be set by a server response; it prevents JavaScript from reading that cookie. A JavaScript-created cookie cannot be HttpOnly:
document.cookie =
"display-mode=compact; Path=/; Max-Age=2592000; SameSite=Lax; Secure";For a server session, the server should set the cookie in an HTTP response, for example:
Set-Cookie: __Host-session=opaque-random-value; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=1800Important attributes:
Secure: send the cookie only over secure HTTPS connections. Production session cookies should use it.HttpOnly: prevent page JavaScript from reading the cookie. This reduces direct credential theft through many XSS attacks, but does not prevent injected script from acting as the user while it runs.SameSite: control cross-site sending.Strictis restrictive;Laxpermits some top-level navigations and is a common baseline;Nonepermits cross-site use and requiresSecurein modern browsers. Exact behavior includes browser policy and context.Path: limit which request paths receive the cookie. It is not a strong security boundary between applications on one host.Domain: a cookie withoutDomainis host-only. Setting a parent domain broadens which subdomains can receive it; avoid that unless necessary.ExpiresorMax-Age: set persistence.Max-Ageis a relative lifetime; a cookie without either is usually a session cookie, though session restoration may affect its apparent lifetime.
The __Host- prefix requires Secure, Path=/, and no Domain attribute in supporting browsers, helping constrain the cookie to one host. The server should rotate and invalidate session identifiers appropriately. Cookie expiration in the browser is not a replacement for server-side session expiration and revocation.
Cookies have size and count limits that vary by browser and should remain small. Never store large documents or extensive profile data in cookies: every matching request would send them again, increasing bandwidth and exposure. Do not assume document.cookie can read all cookies; HttpOnly cookies are intentionally hidden, and scope rules apply.
Authentication: JWT, XSS, and CSRF Trade-offs
A JSON Web Token (JWT) is a signed token format containing claims. Signing can let a server detect tampering, but it does not necessarily encrypt the claims. Anyone who can read a JWT can often decode its payload. Validate issuer, audience, expiration, signature, and allowed algorithms on the server; do not use a browser-decoded claim as authorization proof.
Where to store a token is a security trade-off, not a one-line rule:
- A token in
localStorageis accessible to JavaScript running on the origin. A cross-site scripting (XSS) flaw can read and exfiltrate it, and it may remain available after a tab closes. - A session cookie marked
HttpOnlycannot be directly read by JavaScript, reducing one route for token theft. Since the browser sends cookies automatically, cross-site request forgery (CSRF) must be considered for state-changing endpoints. Secureprotects transmission over insecure HTTP, but does not protect a token from malicious same-origin script or a compromised device.SameSitereduces some cross-site cookie sending but does not eliminate all CSRF concerns, especially when application behavior or related origins are involved.
For cookie-authenticated state-changing requests, combine appropriate SameSite settings with a deliberate CSRF defense, such as validated anti-CSRF tokens and/or strict Origin/Referer checks where applicable. Use safe HTTP methods correctly: a GET must not perform a state change. For JavaScript-readable bearer tokens, protect against XSS with contextual output encoding, safe DOM APIs, dependency controls, and a strong Content Security Policy; no storage location makes XSS harmless.
Use short-lived credentials, server-side authorization on every protected operation, and a session revocation/rotation strategy. Avoid placing bearer tokens in URLs, where they can leak through history, logs, and referrers. Choose an architecture based on the actual threat model and server capabilities, and review it with the security team for sensitive applications.
Offline Work and a Synchronization Queue
Offline-first interfaces can let users create or edit data without a connection. A robust design stores an operation locally as pending, presents its synchronization state, retries when connectivity returns, and reconciles conflicts with the server. The browser's online event is only a hint; it does not prove the server is reachable.
Each queued operation should include enough information to replay safely, such as a unique idempotency key, entity identifier, operation type, payload, creation time, and retry count. Keep the queue in IndexedDB for durable asynchronous storage. Do not queue secrets unnecessarily, and define how the UI behaves if local persistence fails.
function makeQueueEntry(note) {
return {
id: crypto.randomUUID(),
entityId: note.id,
type: "update-note",
payload: note,
createdAt: Date.now(),
attempts: 0,
};
}The following outline sends entries in order and deletes an entry only after a confirmed success. Production applications should persist and update entries in IndexedDB transactions rather than an in-memory array:
async function syncQueue(entries, { signal } = {}) {
for (const entry of entries) {
if (signal?.aborted) break;
try {
const response = await fetch(`/api/notes/${encodeURIComponent(entry.entityId)}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": entry.id,
},
body: JSON.stringify(entry.payload),
signal,
});
if (response.ok) {
await removeQueueEntry(entry.id);
} else if (response.status === 409) {
await markQueueConflict(entry.id, await response.json());
} else if (response.status === 401 || response.status === 403) {
pauseQueueForAuthentication();
return;
} else {
await recordQueueFailure(entry.id, response.status);
return;
}
} catch (error) {
if (error.name === "AbortError") return;
await recordQueueFailure(entry.id, null);
return;
}
}
}PUT is often intended to be idempotent, but an API's exact semantics and idempotency-key support must be confirmed. A 409 Conflict or version mismatch needs a policy: ask the user to resolve it, merge non-overlapping changes, or let the server's canonical version win. Silently overwriting newer server data can destroy user work.
A sync process should use bounded backoff with jitter, pause when credentials expire, and avoid retrying permanent validation failures forever. Service workers and Background Sync can improve reliability where supported, but support and execution timing vary; retain a foreground synchronization path and visible status such as “Saved on this device,” “Syncing,” or “Needs attention.”
Debugging and Best Practices
- In browser developer tools, inspect Application/Storage panels for Web Storage, IndexedDB databases, and cookies. Cookie panels show attributes that
document.cookiecannot reveal. - Reproduce with storage disabled, in private mode, or after clearing site data. A feature that only works in a warm development profile is not robust.
- Test malformed stored JSON, a missing key, quota failure, and IndexedDB upgrade errors. Stored data can outlive code versions.
- Version stored records and validate them when reading. A deployment may encounter data written by an older schema.
- Use Web Storage for small, infrequent settings; IndexedDB for structured or larger data; cookies for small request-associated state.
- Keep credentials out of local/session storage unless the architecture deliberately accepts JavaScript readability risks. Never use browser storage as an authorization check.
- Use
HttpOnly,Secure, suitableSameSite, host-only scope, and server-managed expiration for session cookies. Include server-side expiry and revocation. - Make offline status honest. A queued change is not yet a server-confirmed change, and
navigator.onLineis not a definitive health check. - Provide deletion and retention controls. Explain persistence when user data is saved locally and consider shared-device privacy.
- Do not persist more personal data than the product requires. Storage is exposed to the user, can be cleared, and may be inspected by scripts running in the origin.
Hands-on Exercise 1: Persist a Theme Preference
Implement a theme selector that stores one of three known values, handles malformed saved content, and continues to work when storage is blocked.
Steps
- Add a labeled select with
light,dark, andsystemoptions. - Read the stored value inside
try/catch. - Parse and validate the saved value against an allowlist.
- Apply a validated preference; otherwise choose
system. - Save only allowed values, and keep the UI working if
setItem()throws. - Test with valid JSON, malformed JSON, an unknown theme, and blocked storage.
Complete solution
<label for="theme">Color theme</label>
<select id="theme">
<option value="system">Use device setting</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<p id="theme-status" role="status"></p>const themeSelect = document.querySelector("#theme");
const themeStatus = document.querySelector("#theme-status");
const allowedThemes = new Set(["system", "light", "dark"]);
function applyTheme(theme) {
document.documentElement.dataset.theme = theme;
}
function loadTheme() {
try {
const raw = localStorage.getItem("theme-choice");
if (raw === null) return "system";
const value = JSON.parse(raw);
if (allowedThemes.has(value)) return value;
localStorage.removeItem("theme-choice");
} catch {
try {
localStorage.removeItem("theme-choice");
} catch {
// Storage is unavailable; the default still works.
}
}
return "system";
}
const initialTheme = loadTheme();
themeSelect.value = initialTheme;
applyTheme(initialTheme);
themeSelect.addEventListener("change", () => {
const selected = themeSelect.value;
if (!allowedThemes.has(selected)) return;
applyTheme(selected);
try {
localStorage.setItem("theme-choice", JSON.stringify(selected));
themeStatus.textContent = "Theme preference saved.";
} catch {
themeStatus.textContent = "Theme applied for this visit; it could not be saved.";
}
});The preference is validated before it affects the page. Persistence failure does not prevent the current selection from taking effect, and the status text accurately distinguishes saved from temporary behavior.
Hands-on Exercise 2: Queue Offline Note Updates
Design a minimal queue record and a synchronization flow that preserves unsynced changes, pauses on authentication failure, and records conflicts instead of overwriting server data.
Steps
- Create a queue entry with a unique ID and an update payload.
- Store and retrieve entries from an IndexedDB object store named
outbox. - Send each item with a stable idempotency key.
- Remove only a confirmed success; mark conflicts for user review.
- Pause on
401or403, and retain transient failures for a later retry. - Show the user whether changes are pending, synced, or require attention.
Complete solution
function openOutbox() {
return new Promise((resolve, reject) => {
const request = indexedDB.open("notes-offline", 1);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains("outbox")) {
db.createObjectStore("outbox", { keyPath: "id" });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function transactionDone(transaction) {
return new Promise((resolve, reject) => {
transaction.oncomplete = resolve;
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error);
});
}
async function putOutbox(db, entry) {
const transaction = db.transaction("outbox", "readwrite");
transaction.objectStore("outbox").put(entry);
await transactionDone(transaction);
}
async function listOutbox(db) {
return new Promise((resolve, reject) => {
const transaction = db.transaction("outbox", "readonly");
const request = transaction.objectStore("outbox").getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function deleteOutbox(db, id) {
const transaction = db.transaction("outbox", "readwrite");
transaction.objectStore("outbox").delete(id);
await transactionDone(transaction);
}
async function queueNoteUpdate(db, note) {
const entry = {
id: crypto.randomUUID(),
entityId: note.id,
payload: note,
createdAt: Date.now(),
state: "pending",
};
await putOutbox(db, entry);
showSyncState("Saved on this device; waiting to sync.");
}
async function synchronizeNotes(db) {
const entries = await listOutbox(db);
for (const entry of entries) {
try {
const response = await fetch(
`/api/notes/${encodeURIComponent(entry.entityId)}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": entry.id,
},
body: JSON.stringify(entry.payload),
},
);
if (response.ok) {
await deleteOutbox(db, entry.id);
continue;
}
if (response.status === 409) {
await putOutbox(db, {
...entry,
state: "conflict",
serverVersion: await response.json(),
});
showSyncState("A change needs your review.");
continue;
}
if (response.status === 401 || response.status === 403) {
showSyncState("Sign in to continue syncing.");
return;
}
showSyncState("Some changes remain queued for retry.");
return;
} catch {
showSyncState("Offline changes are saved and will retry later.");
return;
}
}
showSyncState("All changes are synced.");
}This simple example has no automatic retry timer and expects the server to support the stated idempotency behavior. A production implementation needs bounded backoff, a conflict-resolution interface, safe schema upgrades, and synchronization triggers when the app starts or returns to the foreground. It intentionally preserves data when the server result is uncertain.
Chapter Summary
Choose storage by lifetime, data size, access pattern, and whether data belongs on requests. Web Storage is simple but synchronous and limited to strings; IndexedDB supports asynchronous structured storage and transactions; cookies carry small request-associated values and need careful attributes. Quotas, eviction, disabled storage, and malformed old data are normal conditions to handle. Authentication storage decisions require weighing XSS against CSRF rather than assuming a single mechanism solves both. Offline synchronization needs durable queue records, safe replay, backoff, conflict handling, and clear user-visible status.