StorageGENERALSPEC-EVOLVINGBROWSER-SPECIFIC

Storage Security and Durability

Two properties decide everything here: anything script can read, every script on the origin can read — and nothing in the browser is durable storage.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

Who can read what I put in browser storage, and what happens when the browser decides it needs the space back?

The user intent

A person expects the application to keep their work and their identity to themselves — not readable by whatever else the page loaded, and still there tomorrow.

The obvious build

Storage is per-origin, so it is isolated. Put whatever the app needs there, and it stays until the user clears it.

Why it breaks

"Per-origin" is a boundary between *sites*, not between the scripts running on one page. The analytics tag, the chat widget and the dependency someone added last quarter all execute on your origin and can read everything in it (Third-Party Scripts and the Supply Chain).

How it breaks in a real browser
  • "Per-origin" is a boundary between *sites*, not between the scripts running on one page. The analytics tag, the chat widget and the dependency someone added last quarter all execute on your origin and can read everything in it (Third-Party Scripts and the Supply Chain).
  • That makes the property to reason about "script-readable", not "isolated". A single successful injection reads every key in Web Storage, every record in IndexedDB, every non-HttpOnly cookie and every cached response, in a few lines and with no user interaction (Cross-Site Scripting).
  • "Until the user clears it" is not the contract. Browsers evict under disk pressure, some remove script-written storage after a period of inactivity, and private windows discard everything on close.
  • Origin is also no longer the only axis. Storage is increasingly partitioned by the top-level site, so the same origin embedded in two different parent sites can see two different stores (The Same-Origin Policy).
  • And nothing here is encrypted in any way that helps against someone holding the device. The browser profile on disk is readable by anything running as that user (The Browser Security Model).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • Every browser store is keyed by origin — scheme, host and port together. A different scheme or a different port is a different origin with a different store, which is why a staging site on another port shares nothing with production.
  • Within one origin there is no further partitioning by script. The platform has no concept of "this key belongs to my bundle and not to the vendor script"; the origin is the whole granularity available (The Browser Security Model).
  • The one exception is HttpOnly on a cookie, which removes it from document.cookie entirely. It is the only browser storage a script on your own origin cannot read, and that is precisely the property it buys (Cookies).
  • Browsers additionally partition storage by top-level site for embedded third-party contexts, so a frame's store depends on which page embedded it. The details differ by browser and are still changing (CORS).
  • Quota is a per-origin budget the browser computes against free disk. When it is exceeded, writes fail; when the device is under pressure, whole origins are evicted, usually least-recently-used first.
  • A persistence request marks an origin as one the browser should try harder to keep. It can be declined, and being granted is not a durability guarantee — it changes the eviction priority, not the physics (IndexedDB).

What this makes the browser do

And which of it is avoidable.

  • On every storage access the browser resolves the calling context's origin — and, where partitioning applies, its top-level site — before it can even find the right store.
  • Under disk pressure the browser walks origins and reclaims space. That work is invisible to the page and produces no event you can catch before the fact.
  • Cookie matching happens per request against the jar, which is why a cookie's exposure is a function of Domain, Path and SameSite rather than of where it was set (Cookies).
  • Clearing site data removes every store for the origin at once — cookies, Web Storage, IndexedDB, Cache Storage and service worker registrations together.

One origin, one store, every script

The property that decides everything in this lesson fits in a sentence: the origin is the finest granularity the platform offers, so any script executing on your page reads any storage your page can read. There is no per-script namespace, no read-only key and no way to hide a value from a vendor tag.

Stating it as a property rather than as a rule about a particular API matters, because it is what lets you reason about a value you have not thought about before. Ask what a script running on your origin would get, how quickly it could be revoked, and what it is worth — then choose where the value lives (Cross-Site Scripting).

What any script on the origin can do — including one you did not write
1// Every browser store is enumerable. None of this needs a vulnerability;
2// it is the ordinary API surface, available to every script on the page.
3async function readEverything() {
4 const dump: Record<string, unknown> = {}
5
6 dump.cookies = document.cookie // every cookie NOT marked HttpOnly
7 dump.local = { ...localStorage } // every key, every value
8 dump.session = { ...sessionStorage }
9
10 dump.databases = await indexedDB.databases?.() // names and versions
11 dump.caches = await caches.keys() // every named cache
12 for (const name of await caches.keys()) {
13 const keys = await (await caches.open(name)).keys()
14 dump[`cache:${name}`] = keys.map((r) => r.url) // and their bodies, on request
15 }
16
17 return dump
18}
19
20// The only value on the page this cannot reach is an HttpOnly cookie —
21// which is also the only one the code that set it cannot read back.

Nothing here is an exploit. It is the documented API, and it is available to the analytics tag, the chat widget and every transitive dependency in your bundle. The interesting question is not how to prevent this call, which you cannot, but what it returns.

Origin, and increasingly site

SPEC-EVOLVINGPartitioning rules, the timeline for third-party cookie restrictions and the availability of storage-access mechanisms differ between Chrome, Firefox and Safari today and are still changing, so the shape below is a model of the direction rather than a description of any one browser's current behaviour.

The second axis is newer and less settled. Historically the origin was the whole story: https://app.example.com had one store, whoever embedded it. Browsers now partition storage for embedded third-party contexts by the top-level site, so the same origin in a frame on two different parent sites can see two different stores.

This is a privacy change with real functional consequences: embedded widgets that assumed a shared store across the web now see an empty one per embedder, and behaviour differs between browsers while the change is in progress. Treat any claim about what a cross-site frame can see as a snapshot, and verify it rather than remember it (CORS).

Top-level: https://news.example          Top-level: https://shop.example
 +---------------------------------+    +---------------------------------+
 | first-party storage             |    | first-party storage             |
 |   news.example                  |    |   shop.example                  |
 |                                 |    |                                 |
 |  <iframe widget.example>        |    |  <iframe widget.example>        |
 |   +-------------------------+   |    |   +-------------------------+   |
 |   | widget.example storage  |   |    |   | widget.example storage  |   |
 |   | keyed by (widget, news) |   |    |   | keyed by (widget, shop) |   |
 |   +-------------------------+   |    |   +-------------------------+   |
 +---------------------------------+    +---------------------------------+
            |                                        |
            +----------  SAME ORIGIN  ---------------+
                     DIFFERENT STORES

Same-origin still means "no other origin reads this".
It no longer means "one store per origin, everywhere on the web".

Nothing here is durable storage

The durability half of this lesson gets far less attention than the exposure half and causes at least as many incidents. Every browser store is quota-managed and evictable. A persistence request changes eviction priority; it does not make the data permanent, and it can be declined.

The practical consequence is a design rule: every store is either a cache of something you can recover, or a copy the user can lose. Decide which, in the code, and make the empty state behave accordingly — because "the store is empty" and "this is a new user" are different facts that look identical at startup (Who Owns This State?).

Exposure and durability failures, and what they look like
TriggerSymptomCauseResponse
Injected or third-party script executes on the originData appears elsewhere; sessions are used from unfamiliar placesNo within-origin partitioning: every script reads every script-readable storeReduce what is stored and how long it is useful; defend upstream with escaping and a policy (Content Security Policy).
Device runs low on diskA returning user is treated as brand new; offline data is goneEviction reclaimed the origin's storage, least-recently-used firstRequest persistence, and make the empty state offer recovery instead of resetting (Offline UX).
Sign-out clears the token onlyThe next user on a shared device sees the previous user's dataCached responses, IndexedDB records and Cache Storage entries were left behindClear every store on sign-out, and verify it in the Application panel rather than assuming.
The site is reached over a different host or schemePreferences and sessions "disappear" on a URL that looks the sameOrigin includes scheme, host and port; a different one is a different storeRedirect to one canonical origin, and treat the others as entry points only (The Same-Origin Policy).
An embedded widget is loaded from a second parent siteState provisioned once appears twice, or not at allStorage partitioned by top-level site gives the same origin a per-embedder storeDo not assume cross-site continuity; make the server the source of identity (What the Frontend Is Responsible For in Auth).
A value is encrypted before being storedA review signs it off and the exposure is unchangedThe decryption key is reachable by the same script that reads the ciphertextTreat it as obfuscation, and spend the effort on reducing what is stored and how fast it can be revoked.

How to build it

Most important first.

  • Decide per value: what happens if this is read, and what happens if this is gone? Those two questions are the whole design. Storing something you cannot afford to have read is a decision, not an accident (Sensitive Data Classification in Security Engineering).
  • Minimise what is script-readable. If the server can hold it, let the server hold it; if the browser must carry it on requests and script has no need to see it, an HttpOnly cookie removes an entire exfiltration path — at the cost of your own code no longer being able to inspect or refresh it (Cookies vs Script-Readable Tokens).
  • Treat injection as the threat that matters here. Nothing you can do inside storage mitigates a script running on your origin, so the defence is upstream: escaping, a content security policy, and a hard limit on what third-party code you admit (Content Security Policy).
  • Scope lifetime deliberately. sessionStorage for something that should not outlive the tab, a short Max-Age for a cookie, an explicit deletion on sign-out for everything else (Session Expiry and the Refresh Race).
  • Clear on sign-out, and clear everything — not just the token. Cached API responses, IndexedDB records and Cache Storage entries are the previous user's data on a shared device (Authorization-Aware UI).
  • Assume eviction. Every store is a cache of something recoverable, or it is a copy the user could lose; design the empty state to recover rather than to look like a fresh install (Offline UX).
  • Do not rely on the store as a security control at all. Anything the client can write, the client can forge; the server re-checks or it is not checked (What the Frontend Is Responsible For in Auth).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • Accessibility preferences are among the most valuable things to persist and among the most damaging to lose. Someone whose reduced-motion or contrast setting is dropped by an eviction meets a site that is not merely unfamiliar but unusable (Contrast, Colour and Motion).
  • Prefer the platform signal as the source of truth and stored state as an explicit override, so an eviction degrades to the operating system preference rather than to your default (Semantics Before ARIA).
  • When persisted work is genuinely lost, say so in an announced, focusable message and offer a recovery path. An empty form with no explanation is unreadable as an error by anyone not looking at the screen (Live Regions and Announcement).
  • Sign-out that clears storage should move focus deliberately and announce what happened; a silent state wipe with focus left on a control that no longer exists strands keyboard and screen-reader users (Focus Management).

What can go wrong

Failure modes
  • A successful injection reads the whole origin's storage and posts it elsewhere. This is not a storage vulnerability — it is what happens after one, and it is why the exposure profile matters when choosing where a value lives (Cross-Site Scripting).
  • A third-party script, or a compromised dependency, reading storage as normal behaviour. It is not an attack in any technical sense; it is code you invited running with your page's full authority (Third-Party Scripts and the Supply Chain).
  • Eviction removing an offline store, and the application interpreting the empty state as a new user and overwriting good server state with nothing (Optimistic UI).
  • A shared or kiosk device retaining a previous user's cached responses because sign-out cleared a token and nothing else.
  • Data appearing to vanish because the same site was reached over a different scheme, host or port — a different origin, and therefore a different store.
  • A partitioned third-party context seeing an empty store and re-provisioning, producing duplicate records the moment the user visits from another parent site.
  • The mitigation failing: encrypting a value in localStorage with a key that is also in localStorage, which stores the lock next to the key and mainly obscures the problem from the team (Hashing vs Encryption vs Encoding in Security Engineering).
What can arrive out of order
  • Sign-out in one tab racing a write in another: the second tab repopulates storage the first just cleared, leaving the previous user's data behind on a shared device (Auth Across Tabs).
  • Eviction happening between a read and a dependent write, so code that checked for the record writes as if it were still there.
  • A partitioned context provisioning fresh state while an unpartitioned copy still exists, producing two stores that both look authoritative.
Security
  • The browser enforces the origin boundary absolutely: no cross-origin script reads this store, and no amount of application code changes that (The Same-Origin Policy).
  • The browser enforces HttpOnly, Secure and SameSite on cookies absolutely. Those are the only per-value controls the platform offers over any storage.
  • The browser enforces nothing at all *within* an origin. There is no per-script permission, no read-only key, no way to hide a value from the vendor tag two lines above yours.
  • What an attacker gets from a successful injection is therefore everything script-readable, immediately — which is why the meaningful question is not which store to use but how much is stored, for how long, and how quickly it can be revoked (XSS Defense by Output Context in Security Engineering).
  • A service worker is the most durable position on this list: it survives reloads, controls future requests and writes the cache the page reads. Registration is restricted to the origin and to secure transports for exactly that reason (The Service Worker Lifecycle).
Misreads
  • "Storage is per-origin, so it is isolated." It is isolated from other origins and shared by every script on yours. Those are very different guarantees.
  • "Cookies are more secure than other storage." An HttpOnly cookie is invisible to script; a cookie without it is exactly as script-readable as anything else, and it is additionally sent on cross-site requests (Cross-Site Request Forgery).
  • "Encrypting the value fixes it." The key has to live somewhere the same script can reach, so the same injection reads both (Hashing vs Encryption vs Encoding in Security Engineering).
  • "If it is in storage, it survives." Eviction, private windows, clearing site data and inactivity policies are all normal browser behaviour.
  • "Nobody can see this — it is on the user's own machine." The user's machine is also the attacker's machine in every threat model where the attacker is not the user.
  • "Obfuscating the key name helps." It is a listing API. Every store here can be enumerated in one call (The Browser Security Model).

Measuring it, and what changes in the field

How you would see this
  • The Application panel has a clear-site-data control and a per-store listing, which together are how you verify that sign-out actually cleared what you think it cleared (A Mental Model of the Devtools).
  • A storage-estimate call reports usage against the origin's current quota — the leading indicator for eviction pressure and for writes that are about to start failing.
  • A content security policy in report-only mode shows what is executing and where it can send data, which is the practical measurement of "who can read my storage" (Content Security Policy).
  • In the field, count storage-empty-on-startup events for returning users; a rise is eviction, and nothing local will show it to you (Real User Monitoring).
  • Session replay tools frequently capture storage contents, so what you store becomes what your vendor stores — audit that before it becomes a disclosure (Session Replay and the Privacy It Costs).
Slow device, slow network, large data, old tab
  • On a device short of disk, eviction is routine rather than exceptional, and the origins evicted are the ones used least recently — which is the user who comes back after a month.
  • In a private window, storage is typically ephemeral and small, and some stores may be unavailable entirely.
  • In a third-party frame, partitioning means the store you see depends on the embedding site, and the rules differ by browser and are still moving.
  • On a shared device, everything persisted is available to the next person unless sign-out removed it.
  • In a long-lived tab, storage written months ago by an older build is still there and is still an input to today's code (Long-Lived Clients and Version Skew).
What this costs
  • Storing less is safer and worse: no offline mode, no resumable drafts, no remembered preferences. The point is not to store nothing, it is to know what each value costs if read and what it costs if lost.
  • An HttpOnly cookie removes a value from script's reach and removes your ability to inspect, refresh or clear it from the client. That is a genuine trade with real operational consequences, not a free upgrade (Cookies vs Script-Readable Tokens).
  • Short lifetimes shrink the window in which stolen state is useful and make people sign in more often, which has its own accessibility and abandonment cost (Session Expiry and the Refresh Race).
  • Requesting persistence protects data you care about and consumes a device budget you do not own, which the browser may decline to give you.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALOrigin scoping, the absence of any within-origin partitioning, and the HttpOnly exception are specified and identical across Blink, Gecko and WebKit; an application can rely on all three.
  • SPEC-EVOLVINGPartitioning of storage by top-level site, the future of third-party cookies and the exact semantics of storage buckets and persistence are actively changing and currently differ between Chrome, Firefox and Safari — verify against current browser documentation rather than a lesson.
  • BROWSER-SPECIFICEviction policy and inactivity-based deletion are implementation choices: Safari has removed script-written storage after a period without user interaction, while Chrome and Firefox evict primarily under disk pressure, so the same code loses data on different schedules.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Software Design — "what happens if this value is read, and what happens if it is gone" is a data-classification question that belongs in the design of the feature, not in the storage call at the end of it.