During an authorised security review for a client last year I opened DevTools on their admin dashboard, went to Application, then Local Storage, and found a JWT with a 30-day expiry, an admin: true claim and a refresh token sitting next to it in plain text. The app had a strict CSP, SRI on every script and a clean dependency audit. None of that mattered for this particular risk: any script that ever ran on that origin, including a browser extension with page access, could read both tokens. One bug anywhere on the origin would turn into a month of access.
I wrote this with help from AI tools and checked every snippet, header and storage behaviour myself in current Chrome and Firefox. I am a Principal Software Engineer in Karachi with 9+ years across PHP/Laravel, Vue, Node and .NET microservices, and I build the client-side tools on PixelValut. This is Part 6, the finale of the series Client Trust & Agent Hijack Defense. Part 3 covered SRI and CDN script pinning, Part 4 covered CSP and Trusted Types and Part 5 covered extension and npm postinstall forensics. Those parts were about keeping hostile code out. This one assumes some gets in, and asks what it can take.
📚 Series Roadmap — Client Trust & Agent Hijack Defense
Part 1: Prompt Injection via Docs Your Tools Fetch Part 2: WebMCP & Tool-Calling Attack Surfaces Part 3: Subresource Integrity & CDN Script Pinning Part 4: CSP, Trusted Types & Residual XSS Part 5: Extension & npm Postinstall Forensics Part 6 (This Post): Browser Secrets That Leak: Token Storage, Quotas & Outbound Controls Related earlier posts: OAuth 2.1 & OIDC ID Token PKCE Forensics · Deobfuscating Malicious npm & Browser Payloads · Webhook Security in Production
Direct answer: Keep long-lived credentials out of anything JavaScript can read. Use HttpOnly, Secure, SameSite cookies (ideally __Host- prefixed) for sessions and refresh tokens, and keep short-lived access tokens in memory only. Treat localStorage, sessionStorage, IndexedDB and Cache Storage as readable by any script on the origin, and limit where that script can send data.
Any script running on your origin, whether your own code, a third-party widget or injected code after an XSS bug, has the same access to JavaScript-readable storage, and can make authenticated requests while the page is open. You cannot fully prevent the second part. You can prevent the first part from turning a short incident into a 30-day account takeover.
| Store | Readable by page JS | Survives tab close | Sent automatically | Typical leak |
|---|---|---|---|---|
HttpOnly cookie |
No | Yes (until expiry) | Yes, to matching requests | CSRF if SameSite is weak; not readable by scripts |
| Normal cookie | Yes, via document.cookie |
Yes | Yes | Readable by any page script |
| localStorage | Yes | Yes, indefinitely | No | Long-lived JWTs and refresh tokens |
| sessionStorage | Yes | No (per tab) | No | Same as localStorage, shorter window |
| IndexedDB | Yes | Yes | No | Offline caches holding PII or API keys |
| Cache Storage / service worker | Yes | Yes | No | Cached authenticated API responses |
| In-memory variable | Only while page runs | No | No | Smallest window; lost on reload |
The row that surprises people is Cache Storage. A service worker that caches /api/me or /api/invoices for offline use is storing authenticated responses on disk where any same-origin script can call caches.match() and read them.
Start with your own app in a staging environment, signed in as a test user. Open DevTools, go to the Application (Chrome) or Storage (Firefox) panel, and walk through each store: Cookies, Local Storage, Session Storage, IndexedDB and Cache Storage. For each entry, note the key name, what it holds and whether it is needed. Then check how much the origin is using:
// Run on your own app, in staging: how much does this origin store?
const { usage, quota } = await navigator.storage.estimate();
console.log(`usage ${(usage / 1024).toFixed(1)} KB of ${(quota / 1048576).toFixed(0)} MB quota`);
console.log('persisted:', await navigator.storage.persisted());
I record the findings in a short inventory that lives in the repo, so reviews can spot new entries:
- store: localStorage
key: ui.theme
contains: "light|dark"
sensitive: false
- store: cookie
key: __Host-session
contains: opaque session id
sensitive: true
flags: [Secure, HttpOnly, SameSite=Lax]
- store: cacheStorage
key: api-v1
contains: cached GET /api/catalog (public)
sensitive: false
Anything marked sensitive outside an HttpOnly cookie deserves a look. For test-user JWTs, paste the value into a JWT decoder to check the exp, scope and role claims; a 30-day admin token in localStorage is a very different finding from a five-minute read-only one. The decoding runs in your browser, but for production tokens I still prefer revoking them after the audit.
navigator.storage.estimate() is not just a performance tool. Origins get a large quota, often a meaningful fraction of free disk, and that space is readable by any script on the origin. Two things I check:
navigator.storage.persist(), the browser is less likely to evict that data under pressure. That is right for offline editors and wrong for anything holding sensitive responses.
Clear sensitive stores on logout explicitly, and send Clear-Site-Data from your logout endpoint so the browser wipes what your code forgot:
HTTP/1.1 204 No Content
Clear-Site-Data: "cache", "cookies", "storage"
Set-Cookie: __Host-session=; Max-Age=0; Path=/; Secure; HttpOnly; SameSite=Lax
The pattern I use for SPAs is a backend-for-frontend (BFF) or at least cookie-based refresh:
Set-Cookie: __Host-refresh=<opaque>; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=1209600
js
// Access token lives only in a closure; refreshed via HttpOnly cookie
let accessToken = null;
async function api(path, opts = {}) {
if (!accessToken) accessToken = (await (await fetch('/auth/refresh', {
method: 'POST', credentials: 'same-origin' })).json()).access_token;
const res = await fetch(path, { ...opts, headers: { ...opts.headers,
Authorization: `Bearer ${accessToken}` } });
if (res.status === 401) { accessToken = null; return api(path, opts); }
return res;
}
The __Host- prefix forces Secure, Path=/ and no Domain attribute, so a subdomain cannot plant or override the cookie. Keep access tokens short-lived, five to fifteen minutes, and bind refresh tokens to rotation so a stolen one is detected on reuse. If your API also verifies signed tokens, test that it rejects tampered ones; the JWT signature verifier is handy for checking that a modified payload with the original signature fails.
Keeping secrets unreadable is the main defence. The second layer is limiting which destinations the page is allowed to contact, so injected code has fewer places to send anything. CSP covers most of these channels:
| Outbound channel | CSP directive | Notes |
|---|---|---|
fetch , XHR, WebSocket,sendBeacon |
connect-src |
Allowlist your API and known analytics only |
| Images and pixels | img-src |
Avoid broad wildcards such as https: |
| Form submissions | form-action |
'self' for most apps |
| Frames | frame-src ,child-src |
List the payment or video providers you embed |
| Top-level navigation | No reliable directive | Another reason secrets must not be readable |
| Extensions with page access | Not controllable by the site | Only HttpOnly storage protects credentials |
A tight starting point for an SPA that talks to one API:
Content-Security-Policy: default-src 'self'; connect-src 'self' https://api.example.com;
img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'
Use Content-Security-Policy-Report-Only first and read the reports, as covered in Part 4, before enforcing. The last two table rows are the point of this finale: some channels cannot be closed from your side, so the only reliable protection is that the credential was never readable by page scripts.
__Host-, Secure, HttpOnly, SameSite=Lax or Strict. Clear-Site-Data. navigator.storage.estimate() usage is checked after releases.connect-src, img-src and form-action.
For short-lived, low-privilege tokens in low-risk apps it is a trade-off some teams accept. For admin, financial or long-lived tokens, no.
No. Injected script can still make authenticated requests while the page is open. HttpOnly stops it from stealing the credential for later use elsewhere.
SameSite=Strict break anything?
It can break flows where users arrive from another site and expect to be signed in. Many apps use Lax for the session and Strict for the refresh cookie.
Encryption in the browser needs a key the same script can read, so it rarely stops same-origin attackers. It mainly helps against casual disk inspection.
In a debug build, set a breakpoint on storage writes in DevTools or wrap your own storage helper to log the caller, then reproduce the flow.
Support is good in current Chromium and Firefox and improving in Safari. Keep explicit client-side clearing as a backup.
That closes Client Trust & Agent Hijack Defense. Across six parts we went from untrusted docs and agent tools to SRI, CSP, supply-chain forensics and finally storage. The thread through all of it: assume some code you did not write will run, and make sure it finds nothing worth taking.