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.
Who can read what I put in browser storage, and what happens when the browser decides it needs the space back?
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.
Storage is per-origin, so it is isolated. Put whatever the app needs there, and it stays until the user clears it.
"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).
- "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-
HttpOnlycookie 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).
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
HttpOnlyon a cookie, which removes it fromdocument.cookieentirely. 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,PathandSameSiterather 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).
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 HttpOnly7 dump.local = { ...localStorage } // every key, every value8 dump.session = { ...sessionStorage }9 10 dump.databases = await indexedDB.databases?.() // names and versions11 dump.caches = await caches.keys() // every named cache12 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 request15 }16 17 return dump18}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
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?).
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Injected or third-party script executes on the origin | Data appears elsewhere; sessions are used from unfamiliar places | No within-origin partitioning: every script reads every script-readable store | Reduce what is stored and how long it is useful; defend upstream with escaping and a policy (Content Security Policy). |
| Device runs low on disk | A returning user is treated as brand new; offline data is gone | Eviction reclaimed the origin's storage, least-recently-used first | Request persistence, and make the empty state offer recovery instead of resetting (Offline UX). |
| Sign-out clears the token only | The next user on a shared device sees the previous user's data | Cached responses, IndexedDB records and Cache Storage entries were left behind | Clear every store on sign-out, and verify it in the Application panel rather than assuming. |
| The site is reached over a different host or scheme | Preferences and sessions "disappear" on a URL that looks the same | Origin includes scheme, host and port; a different one is a different store | Redirect 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 site | State provisioned once appears twice, or not at all | Storage partitioned by top-level site gives the same origin a per-embedder store | Do 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 stored | A review signs it off and the exposure is unchanged | The decryption key is reachable by the same script that reads the ciphertext | Treat 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
HttpOnlycookie 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.
sessionStoragefor something that should not outlive the tab, a shortMax-Agefor 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
- 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
localStoragewith a key that is also inlocalStorage, which stores the lock next to the key and mainly obscures the problem from the team (Hashing vs Encryption vs Encoding in Security Engineering).
- 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.
- 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,SecureandSameSiteon 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).
- "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
HttpOnlycookie 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
- 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).
- 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).
- 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
HttpOnlycookie 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
HttpOnlyexception 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.
- — 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.