Browser Secrets That Leak: Token Storage, Quotas & Outbound Controls
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 to
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
Where should a web app store auth tokens so they don't leak?
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.
The threat model in one sentence
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.
What each browser store actually exposes
| 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.
Step 1: audit what is actually stored
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:
# storage-inventory.yml
- 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.
Why storage quotas matter for security
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:
- Unexpected growth. If usage jumps after a release, something new is caching. In one review it was a chat widget storing full conversation transcripts in IndexedDB.
-
Persistence. If the app calls
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
Step 2: move tokens where scripts cannot reach
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
// 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.
Step 3: limit where data can be sent
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.
Diagnostic checklist
- [ ] No refresh tokens, API keys or long-lived JWTs in localStorage, sessionStorage or IndexedDB.
- [ ] Session and refresh cookies are
__Host-,Secure,HttpOnly,SameSite=LaxorStrict. - [ ] Access tokens are in memory only, with a lifetime of 15 minutes or less.
- [ ] Refresh tokens rotate and reuse triggers revocation.
- [ ] Service worker does not cache authenticated API responses, or encrypts and clears them on logout.
- [ ] Logout sends
Clear-Site-Data. - [ ] A storage inventory is kept in the repo and
navigator.storage.estimate()usage is checked after releases. - [ ] CSP restricts
connect-src,img-srcandform-action. - [ ] Third-party widgets are reviewed for what they store on your origin.
FAQ
Is localStorage ever fine for tokens?
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.
Do HttpOnly cookies stop XSS?
No. Injected script can still make authenticated requests while the page is open. HttpOnly stops it from stealing the credential for later use elsewhere.
Does 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.
Should I encrypt values in localStorage?
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.
How do I find which script wrote a storage key?
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.
Does Clear-Site-Data work everywhere?
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.
Follow & Connect
- Website: PixelValut Tools
- YouTube: PixelValut (@pixel-valut)
- TikTok: PixelValut (@pixelvalut007)
- GitHub: Muhammad Ali Mughal (@ali111887)
- LinkedIn: Muhammad Ali Mughal
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.