Storage Partitioning & Privacy Controls
A storage value your app wrote last week may simply not be there today — not because of a bug, but because the browser deliberately partitioned, capped, or wiped it. Modern engines now treat client-side storage as a privacy surface to be controlled, not a durable drawer the origin owns. Third-party contexts get their storage keyed to the top-level site, Safari’s Intelligent Tracking Prevention caps script-writable storage to roughly seven days for sites the user has not installed, and every browser evicts under pressure. For the security model these mechanics sit inside, this guide builds on Storage Security, Encryption & Privacy. The takeaway for engineers is structural: design so that a wipe is recoverable in one request, never treat the presence of a local value as proof of authentication, and request the durability and access you need explicitly through the platform APIs.
Why partitioning exists and what it changes
For years a third-party origin embedded across many sites — an ad network, a chat widget, a CDN — shared one storage jar regardless of which page hosted it. That shared jar was the engine of cross-site tracking: a script could write an identifier on site-a.com and read it back on site-b.com. Browsers closed that channel by partitioning third-party storage on the top-level site. When widget.cdn runs inside an iframe on shop.example, it gets a storage bucket keyed to shop.example; the same widget.cdn iframe on news.example gets a completely separate bucket. Cookies, localStorage, IndexedDB, the Cache API, and BroadcastChannel are all partitioned this way in embedded contexts.
This is not a niche edge case. Safari has partitioned third-party storage for years under ITP. Chrome ships state partitioning that isolates third-party storage by the top-level site, and Firefox enforces the same isolation through its Total Cookie Protection model. The practical consequence: any code that assumed a single shared store across embedding sites is now reading and writing per-embedder buckets. A widget that signed a user in once and expected the session to follow them everywhere will find each host site a fresh, empty context.
The second force is lifetime capping. Safari’s Intelligent Tracking Prevention places a hard ceiling on script-writable storage for origins the user has not engaged with as an installed application. Values written through script — localStorage, IndexedDB, the Cache API, and any cookie set via document.cookie rather than a server Set-Cookie header — are capped at a roughly seven-day lifetime, and a full wipe follows extended inactivity. The mechanics overlap heavily with quota-driven eviction covered in Storage Quotas & Eviction Policies, but the trigger is different: ITP clears on a privacy timer, not under disk pressure.
The privacy-control API surface
Three native APIs let an application respond to partitioning and capping deliberately rather than discovering them through user complaints. The Storage Access API lets an embedded frame request unpartitioned cookie access after a user gesture. The Storage Manager’s persist() requests durable storage that resists eviction. And CHIPS (Cookies Having Independent Partitioned State) lets a server opt a cookie into partitioned-but-durable behavior. The signatures and parameters below are the load-bearing surface.
| Method / mechanism | Signature | Returns | Notes |
|---|---|---|---|
| Has access | document.hasStorageAccess() |
Promise<boolean> |
True if the frame already has unpartitioned cookie access |
| Request access | document.requestStorageAccess() |
Promise<void> |
Must follow a user gesture; rejects if denied. Per-frame, not persisted across navigations |
| Request access (scoped) | document.requestStorageAccess({ ... }) |
Promise<{...}> |
Newer form for non-cookie storage handles; support is uneven |
| Persist | navigator.storage.persist() |
Promise<boolean> |
Asks for durable storage; resolves to whether the bucket is now persistent |
| Check persistence | navigator.storage.persisted() |
Promise<boolean> |
Reports current durability without prompting |
| Estimate usage | navigator.storage.estimate() |
Promise<{usage, quota}> |
Reports bytes used and the origin’s quota |
| Partitioned cookie | Set-Cookie: name=v; Secure; Path=/; SameSite=None; Partitioned |
(HTTP header) | CHIPS — cookie is keyed to the top-level site, survives as a first-class partitioned cookie |
requestStorageAccess() is the centerpiece for embedded flows. It must be called from within a user-activation event (a click or tap handler), it applies only to the current document, and the grant does not persist across page loads — a reload returns the frame to its partitioned state and the request must run again. Treat it as a per-frame, per-session capability, never a one-time setup step.
Implementation walkthrough
The defensive pattern is to feature-detect each capability, request the access and durability you need at the right moment, and architect so that none of it is load-bearing for correctness. The walkthrough below covers an embedded context that needs unpartitioned cookies, a top-level app that wants durable storage, and the recovery design that makes a wipe survivable.
Step 1 — Feature-detect and request storage access in an embedded frame
// Run inside an embedded iframe that needs its unpartitioned (first-party)
// cookies — e.g. an SSO widget. Must be triggered by a real user gesture.
async function ensureStorageAccess(): Promise<boolean> {
// Feature-detect: older and privacy-restricted engines may omit the API.
if (typeof document.hasStorageAccess !== 'function') {
return false; // No partitioning concept here, or no way to escape it.
}
// Fast path: access may already be granted for this frame.
if (await document.hasStorageAccess()) {
return true;
}
try {
// Browsers REQUIRE this to be called from a user-activation handler.
await document.requestStorageAccess();
return true;
} catch {
// Denied, dismissed, or called without a gesture. Fall back gracefully.
return false;
}
}
// Wire it to an explicit click so the call carries user activation.
document.querySelector('#sign-in')?.addEventListener('click', async () => {
const granted = await ensureStorageAccess();
if (granted) {
await startAuthenticatedFlow(); // Unpartitioned cookies now available.
} else {
redirectToTopLevelLogin(); // Cannot get cookies in-frame; break out.
}
});
The critical detail is the user gesture. Calling requestStorageAccess() from a load handler, a timer, or a promise chain that has lost its activation context rejects immediately. Always attach it to the click or tap that the user performs to begin the flow.
Step 2 — Request durable storage in the top-level app
// Run in the top-level document (the installed PWA or main site). Durable
// storage resists eviction and ITP capping more strongly once granted.
interface DurabilityReport {
persisted: boolean;
usageBytes: number;
quotaBytes: number;
}
async function requestDurableStorage(): Promise<DurabilityReport> {
const sm = navigator.storage;
if (!sm?.persist) {
return { persisted: false, usageBytes: 0, quotaBytes: 0 };
}
// persisted() reports the current state without prompting the user.
let persisted = await sm.persisted();
if (!persisted) {
// persist() may grant silently based on engagement signals, or prompt.
persisted = await sm.persist();
}
const { usage = 0, quota = 0 } = (await sm.estimate?.()) ?? {};
return { persisted, usageBytes: usage, quotaBytes: quota };
}
Browsers gate persist() on engagement heuristics — bookmarking, installation to the Home Screen, frequent visits, or an explicit permission grant. Call it after the user has demonstrated intent (signed in, saved work), not on first paint, where it is most likely to be silently refused.
Step 3 — Design so a wipe is recoverable
The third step is architectural rather than an API call: never let a security or correctness decision rest on a local value still being present. A wiped client should re-hydrate from the server in a single request.
// A local marker is a hint, never the source of truth. Validate against the
// server so a wipe degrades to one refresh round-trip, not a broken session.
interface SessionHint {
userId: string;
expiresAt: number; // epoch ms
}
function readSessionHint(): SessionHint | null {
try {
const raw = localStorage.getItem('session-hint');
return raw ? (JSON.parse(raw) as SessionHint) : null;
} catch {
return null; // Storage wiped, disabled, or corrupt — treat as no hint.
}
}
async function resolveSession(): Promise<SessionHint | null> {
const hint = readSessionHint();
// Presence + non-expiry is necessary but NOT sufficient. Confirm with server.
const looksUsable = hint !== null && hint.expiresAt > Date.now();
// Always reconcile against the HttpOnly refresh cookie. If the local marker
// was wiped by ITP, this single request restores the session transparently.
const res = await fetch('/auth/session', { credentials: 'include' });
if (!res.ok) return null; // Genuinely logged out.
const fresh = (await res.json()) as SessionHint;
localStorage.setItem('session-hint', JSON.stringify(fresh)); // Re-seed hint.
return looksUsable || fresh ? fresh : null;
}
Because the refresh credential lives in an HttpOnly cookie outside ITP’s script-writable cap (it is server-set, not written via document.cookie), the recovery request succeeds even after the local marker is gone. This is the same principle that governs Securing Auth Tokens in Browser Storage: keep the durable secret out of script reach and let local storage hold only disposable hints.
Concurrency & lifecycle notes
Storage access is per-frame and per-document. A grant obtained in one iframe does not extend to a sibling iframe of the same origin, and it does not survive a navigation or reload — each fresh document starts partitioned and must call requestStorageAccess() again from a new gesture. If your widget appears multiple times on a page, each instance manages its own access state. Plan for the request to run repeatedly across the session, and cache the resulting in-memory state per frame rather than assuming a single global grant.
Durability has its own lifecycle. persist() reflects a per-origin (per-bucket) decision that, once granted, generally holds until the user clears site data, but it is not a guarantee against ITP’s inactivity timer on every engine. Re-check persisted() on cold start rather than caching the result across sessions. When multiple tabs race to request durability or to refresh a session after a wipe, coordinate them with the Web Locks API for Cross-Tab Coordination so you issue one refresh request, not one per tab.
Error handling & fallbacks
Every privacy-control call can fail, and the failures are normal operating conditions rather than exceptions to log and forget. Handle them as branches in the flow.
// Resilient bootstrap: degrade cleanly when each capability is missing.
async function bootstrapPrivacyAware(): Promise<void> {
// 1. Durability is best-effort. A refusal is expected on low-engagement visits.
const durable = navigator.storage?.persist
? await navigator.storage.persist().catch(() => false)
: false;
if (!durable) {
// Lean harder on server recovery and shorter offline windows.
scheduleAggressiveServerSync();
}
// 2. Storage access only matters in embedded contexts. Detect being framed.
const isEmbedded = window.top !== window.self;
if (isEmbedded && typeof document.requestStorageAccess === 'function') {
const hasAccess = await document.hasStorageAccess().catch(() => false);
if (!hasAccess) {
// Cannot prompt without a gesture here; surface an in-frame CTA instead.
showInFrameContinueButton();
}
}
// 3. A wipe is invisible until you read empty storage. Detect cold-start empties.
if (localStorage.length === 0 && (await wasPreviouslyActive())) {
reportTelemetry('storage-wiped-cold-start');
await resolveSession(); // Recover transparently from the server.
}
}
The fallback hierarchy is consistent: when durable storage is refused, shorten how long you rely on cached data and sync to the server sooner; when storage access is unavailable in a frame, surface a user-initiated continue button or break the flow out to the top-level site; when storage appears wiped, recover from the server instead of forcing a re-login. None of these paths should ever show the user an error if the server still holds the session.
Browser compatibility matrix
| Capability | Chrome / Edge | Firefox | Safari (macOS) | iOS Safari 16 / 17 |
|---|---|---|---|---|
| Third-party storage partitioning | Yes (state partitioning) | Yes (Total Cookie Protection) | Yes, partitioned for years | Yes |
| ITP 7-day script-writable cap | No | No | Yes | Yes — aggressive on non-installed origins |
requestStorageAccess() (cookies) |
Yes | Yes | Yes (origin of the API) | Yes; requires a user gesture, grant not persisted |
navigator.storage.persist() |
Yes; engagement-gated | Yes; permission-gated | Limited; Home Screen install is the durable path | Limited; installed PWA gets the durable context |
CHIPS Partitioned cookie |
Yes | Behind flag / rolling out | Partial / evolving | Partial / evolving |
navigator.storage.estimate() |
Yes | Yes | Yes | Yes |
The iOS Safari 16/17 column is where teams get surprised. On those versions a PWA added to the Home Screen runs in a storage context distinct from the in-browser tab, and the in-browser context is subject to the full ITP cap. A user who interacts with your site only in a regular Safari tab can lose all script-writable storage after seven idle days, while the same user who installed the app keeps a far more durable bucket. The mitigation is to encourage installation for offline-critical apps and to architect recovery so the tab-only user is re-hydrated rather than logged out. The hands-on diagnosis of exactly this failure is covered in Debugging Storage Cleared by Safari ITP.
Performance & scale notes
Privacy controls are cheap to call but easy to misuse at scale. hasStorageAccess(), persisted(), and estimate() are lightweight promise-returning checks; cache their results in memory for the document’s lifetime rather than re-querying on every render. persist() and requestStorageAccess() may show a prompt, so call them at most once per meaningful user intent — firing them speculatively on load trains users to dismiss them, which permanently sours the grant. For partitioned cookies via CHIPS, remember that each top-level site keeps its own copy of the cookie, so a widget embedded across thousands of sites multiplies its cookie storage by the number of embedders rather than sharing one. Keep partitioned cookies small and avoid stuffing state into them that belongs in a server-side session keyed by an opaque identifier.
Frequently Asked Questions
Why does my embedded widget lose its login on every host site?
Because third-party storage is partitioned by the top-level site. The same embedded origin gets a separate storage bucket on each embedding page, so a session established under one host is invisible under another. To regain unpartitioned cookie access, call document.requestStorageAccess() from a user gesture inside the frame, and design the widget to authenticate per host or break out to a top-level login.
Does navigator.storage.persist() stop Safari ITP from wiping my data?
It helps but is not a hard guarantee on Safari. ITP’s clearing is driven by a privacy inactivity timer, and on iOS the durable path in practice is installing the app to the Home Screen rather than relying on persist() in a browser tab. Always pair persistence with server-side recovery so a wipe costs one request, as detailed in Storage Quotas & Eviction Policies.
What is the difference between CHIPS partitioned cookies and the Storage Access API?
CHIPS (Set-Cookie: ...; Partitioned) keeps a cookie but scopes it to the top-level site, so each embedder gets an isolated, durable copy — useful when the embedded context only needs its own per-site state. The Storage Access API instead lets a frame request the original unpartitioned (cross-site) cookies after a user gesture, which you need when a widget must share one identity across hosts.
How long does requestStorageAccess() access last?
Only for the current document. The grant is per-frame and does not persist across reloads or navigations, so a fresh page load returns the frame to its partitioned state and you must request access again from a new user gesture. Treat it as a per-session capability rather than one-time setup.
Related
- Storage Security, Encryption & Privacy — the parent guide to the threat model and protections this fits into.
- Debugging Storage Cleared by Safari ITP — hands-on diagnosis of the 7-day wipe and how to recover.
- Securing Auth Tokens in Browser Storage — keeping the durable credential out of script-readable, ITP-capped storage.
- Storage Quotas & Eviction Policies — the quota and eviction mechanics that overlap with privacy capping.
- Web Locks API for Cross-Tab Coordination — coordinating one recovery request across tabs after a wipe.