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. 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 https://dev.to/muhammadalimughal/subresource-integrity-cdn-script-pinning-catching-silent-payload-swaps-1654 , Part 4 covered CSP and Trusted Types https://dev.to/muhammadalimughal/csp-trusted-types-residual-xss-reading-reports-like-a-forensics-log-h4g and Part 5 covered extension and npm postinstall forensics https://dev.to/muhammadalimughal/extension-npm-postinstall-forensics-homoglyphs-base64-unexpected-network-2g6j . 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 https://dev.to/muhammadalimughal/prompt-injection-via-docs-your-tools-fetch-markdown-readmes-issue-bodies-bpg Part 2: WebMCP & Tool-Calling Attack Surfaces https://dev.to/muhammadalimughal/webmcp-tool-calling-attack-surfaces-what-an-e-shop-can-make-an-agent-do-2981 Part 3: Subresource Integrity & CDN Script Pinning https://dev.to/muhammadalimughal/subresource-integrity-cdn-script-pinning-catching-silent-payload-swaps-1654 Part 4: CSP, Trusted Types & Residual XSS https://dev.to/muhammadalimughal/csp-trusted-types-residual-xss-reading-reports-like-a-forensics-log-h4g Part 5: Extension & npm Postinstall Forensics https://dev.to/muhammadalimughal/extension-npm-postinstall-forensics-homoglyphs-base64-unexpected-network-2g6j Part 6 This Post : Browser Secrets That Leak: Token Storage, Quotas & Outbound Controls Related earlier posts: OAuth 2.1 & OIDC ID Token PKCE Forensics https://dev.to/muhammadalimughal/oauth-21-oidc-id-tokens-pkce-forensics-auditing-claims-nonces-audience-confusion-in-spas-2eh9 · Deobfuscating Malicious npm & Browser Payloads https://dev.to/muhammadalimughal/deobfuscating-malicious-npm-browser-payloads-base64-hex-unicode-escapes-homoglyphs-39eb · Webhook Security in Production https://dev.to/muhammadalimughal/webhook-security-in-production-hmac-sha256-signatures-timestamp-drift-replay-protection-4f8a 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: 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 https://pixelvalut.com/tools/jwt-decoder?utm source=devto&utm medium=series&utm campaign=browser-secrets-tokens-storage-exfil 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=