Web Platform · Browser Storage

Storage Partition Keys and the Origin Boundary

How a browser derives the key that web storage is filed under, how scheme and port enter that key, and why partitioned third-party storage adds the top-level site.

Every storage facility in a browser asks the same thing first: which bucket the write belongs to. The answer is not the page’s URL, not the document object, and not the tab. It is a derived key, and the shape of that key explains most of the behaviour that surprises people — data that disappears when a development server moves from port 3000 to 8080, two subdomains that cannot see each other’s values, an embedded widget that finds an empty database on a second site.

An origin is three fields, not a hostname

The same-origin policy compares origins, and an origin is a tuple of scheme, host, and port. Hostnames are compared after lowercasing and after internationalised names are converted to their ASCII form; a missing port is filled in from the scheme’s default, so https://example.com and https://example.com:443 denote the same origin because both normalise to port 443. Everything else splits.

Consequences follow directly. http://example.com and https://example.com are different origins, which is why a site that migrates to TLS does not carry its localStorage with it. https://example.com:8443 is a third origin, which is why a development server that binds a random port has a fresh, empty store on every restart. https://app.example.com and https://www.example.com are separate origins with no implicit parent relationship — the browser never walks up the label hierarchy the way cookie scoping does.

const u = new URL('https://shop.example.com:8443/cart?x=1');
console.log(u.origin);           // "https://shop.example.com:8443"
console.log(u.hostname);         // "shop.example.com"
// Storage below is filed under the full origin, port included.
localStorage.setItem('cart', '[]');

That last point is the one worth internalising: the storage APIs key on the whole origin, while cookies key on domain and path. A cookie set with Domain=example.com from app.example.com is sent to www.example.com and to example.com, and cookies ignore the port entirely. localStorage, IndexedDB, the Cache API, and the origin private file system do none of that. The two mechanisms are commonly discussed together and their scoping rules share almost nothing. The cookie rules have their own article in this cluster, Cookie Scope and the Attributes That Restrict Sending.

What the specifications call a storage key

The Storage Standard gives the bucket identifier a name, the storage key, and as currently written defines it as the origin alone, with a note that additional keying is expected. Browsers implement that additional keying from the Client-Side Storage Partitioning work: for a nested context the key is the origin plus the top-level site, and some implementations also record whether the frame is same-site with its ancestors, so the same embedder at different nesting depths does not collapse into one bucket. A site here is a scheme plus a registrable domain — the public suffix plus one label, so example.co.uk is a site while shop.example.co.uk is not a separate one.

This is the mechanism behind storage partitioning. Before partitioning, a third-party frame served from https://widget.example embedded on a thousand different sites wrote to a single https://widget.example bucket. Anything it stored — an identifier, a preference, a cached response — was readable from every embedding site, which made the frame a cross-site join key whether or not anyone intended it as one. Under partitioning the key for that frame becomes (origin https://widget.example, top-level site https://news.example), and the same frame on a different top-level site lands in a different bucket.

Partitioning is a keying change rather than a separate network stack: the HTTP cache, databases, and cookies are all filed per partition, so a widget embedded on two sites performs its first-run initialisation twice, holds two independent caches, and cannot correlate the two visitors. Safari and Firefox partition third-party storage by top-level site; Chromium does the same for storage while treating third-party cookies separately.

The escape hatch is the Storage Access API, whose document.hasStorageAccess() and document.requestStorageAccess() methods let an embedded frame ask for access to the unpartitioned cookie and storage state of its own origin. The request is gated on user activation and, depending on the browser, on a prior visit to the site as a first party or an existing relationship. It requires explicit user consent in the general case, which is exactly why the platform does not simply grant unpartitioned access to every frame that asks.

Opaque origins produce no storage at all

Not every document has an origin that can serve as a storage key. A data: URL gets a fresh opaque origin on every navigation, and opaque origins compare unequal even to themselves, so a script running from one cannot use persistent storage. A blob: URL inherits the origin of the context that created it, which is why a worker or a downloaded page loaded from a blob can still read the creating document’s data. An iframe with sandbox but without allow-same-origin is forced into an opaque origin, and any localStorage access inside it throws a SecurityError rather than silently writing to a throwaway bucket.

file: URLs are the awkward case. There is no host in the usual sense and no scheme-based security boundary that makes sense, so browsers have historically disagreed: some treat each file as its own opaque origin, some share one bucket across the whole local filesystem. Code that is developed by opening an HTML file directly and later served over HTTP can therefore pass locally and fail once deployed, or the reverse. Treat storage under file: as unavailable.

What the key deliberately ignores

Path, query string, and fragment play no part in the key. A document at /checkout and a document at /support on the same host and port read and write the same localStorage area and the same IndexedDB database, even though they are different URLs and different resource paths on the server. This is not an oversight: the origin is the smallest unit the platform can enforce security on, because script from one path can issue requests to any other path on the same host. A path-scoped storage area would be a partition the code inside it could trivially escape.

Tabs are ignored too. Two tabs showing the same origin share every bucket, and a third tab opened later sees everything the first two wrote. History entries, back/forward navigation, and reloads have no effect on the key. The document’s lifetime is independent of the storage lifetime, which is the entire point of a persistent store: the key outlives the document that used it and is only removed when the user or the browser clears it.

The one structural exception is the partition component described above. A document in a top-level context and the same origin embedded as a third party are two different keys, because the partition component distinguishes them. Injecting the same page into an iframe on another site therefore does not expose the top-level bucket; it produces a fresh one. Debugging storage issues usually starts with establishing which of these cases the code is running in.

Quota is counted per key, not per browser

Because the key defines the bucket, the quota is counted per bucket. navigator.storage.estimate() reports usage and quota for the caller’s storage key, and those numbers cover everything the key owns — IndexedDB records, Cache API entries, origin private file system files — because they are all mediated by the same per-key quota manager. The Storage Standard registers a recommended quota of 5 mebibytes for the localStorage endpoint and leaves the rest to implementation policy; the quota reported for the larger APIs is a conservative estimate derived from the volume’s capacity or a policy cap, not its free space, and implementations recompute it rather than exposing a fixed constant. The details of eviction and how usage is attributed are covered in Quota Accounting and Eviction Under Storage Pressure.

Clearing “cookies and site data” for a site removes the entire bucket: cookies, the localStorage area, IndexedDB databases, Cache API entries, and files in the origin private file system. The HTTP cache is a separate store that most browsers clear through a separate control, which is why a user who clears site data for a domain can lose the databases while cached responses remain. Incognito and private windows use a separate ephemeral key space that is discarded when the last private window closes, so a value written in a private window is invisible to a normal window of the same origin, and the reverse.

Where the boundary is visible in practice

Two tabs of the same origin share a bucket, which is what makes cross-tab coordination necessary at all; the cross-tab coordination article covers the primitives for serialising access. Two tabs of the same site on different ports do not share anything, which breaks the common development setup of a frontend on one port and a second copy on another. A redirect from www to the apex host is a move to a different key, so a session token kept in localStorage is gone unless the application stayed on one hostname throughout.

None of this is a bug in any individual API. It is one policy — the origin — reused by every storage mechanism, and predicting behaviour is mostly a matter of asking which key a given call resolves to before asking what the call does.

References

  1. HTML Standard — Web storage — WHATWG
  2. Storage Standard — WHATWG
  3. RFC 6265: HTTP State Management Mechanism — IETF