cd /news/ai-tools/browser-secrets-that-leak-token-stor… · home › topics › ai-tools › article
[ARTICLE · art-167500] src=dev.to ↗ pub= topic=ai-tools verified=true sentiment=· neutral

Browser Secrets That Leak: Token Storage, Quotas & Outbound Controls

A principal software engineer at PixelValut documented a security review in which a client's admin dashboard stored a 30-day JWT with an admin:true claim and a refresh token in plaintext localStorage, readable by any script on the origin despite strict CSP, SRI and a clean dependency audit. The engineer recommends keeping long-lived credentials out of JavaScript-reachable storage, using HttpOnly, Secure and SameSite cookies (ideally __Host- prefixed) for sessions and refresh tokens, and holding short-lived access tokens in memory only.

by read7 min views1 publishedNov 11, 2026

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.

── more in #ai-tools 4 stories · sorted by recency
── more on @pixelvalut 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/browser-secrets-that…] indexed:0 read:7min 2026-11-11 · —