Security recipes · 07

XSS & token storage

The threat
Use an XSS foothold to exfiltrate authentication tokens from browser storage, turning a transient script injection into durable account takeover.
Sentinel's counter
httpOnly cookie transport with CSRF double-submit as the browser default; bearer tokens held in memory only, never localStorage/sessionStorage, in every first-party client.

The attack

Cross-site scripting is usually discussed as a page-integrity problem. For an identity system it is a credential problem: an injected script runs with your origin’s privileges, and anything your JavaScript can read, it can read. If your SPA keeps an access token — worse, a refresh token — in localStorage, the attacker’s payload is one line: read the key, POST it home. The XSS may live for a single page view; the stolen refresh token lives for weeks and works from the attacker’s own machine, long after the injected script is gone. That asymmetry — transient bug, durable theft — is what makes token storage the highest-stakes decision in a browser client.

And the vector doesn’t have to be your bug: a compromised npm dependency, a poisoned analytics snippet, or a third-party widget all run in the same origin with the same read access.

How Sentinel counters it

The browser default is cookies scripts cannot read. Sentinel’s cookie transport puts both tokens in httpOnly cookies (sentinel_at, sentinel_rt — names from SentinelAspNetOptions), with the refresh cookie path-scoped to the refresh endpoint. An injected script cannot read them, enumerate them, or export them — the theft primitive is gone, not mitigated. Login responses in cookie mode deliberately omit the token fields from the JSON body entirely, so there is no window where script holds a copy.

CSRF is handled, not hand-waved. Cookies reintroduce cross-site request forgery, so the transport ships its counter-measure: a non-httpOnly sentinel_csrf cookie whose value must be echoed in the X-Sentinel-Csrf header on every unsafe cookie-mode request — the classic double-submit, which a cross-site attacker fails because they can trigger the cookie but cannot read it. The TypeScript client does the echo automatically.

Bearer mode refuses to persist — on purpose. For the contexts that genuinely need bearer tokens (APIs, machines, mobile, tests), both first-party clients hold them in memory and nowhere else. The TypeScript SentinelClient documents the rule at the top of the module: tokens are never written to localStorage/sessionStorage, because “anything a page can read, injected script can read”, and memory-only storage bounds the blast radius to the lifetime of the compromised page. The .NET client’s default ISentinelTokenStore is InMemorySentinelTokenStore — a single volatile reference, no persistence; hosts with genuinely protected storage can plug the seam, but they must choose to. There is no configuration flag anywhere in Sentinel that writes a token to browser storage.

Theft that happens anyway is detected. Defense in depth, not defense instead: if a token is exfiltrated through some other channel, rotation and family revocation turn the first concurrent refresh into token.refresh_reuse_detected and a dead token family, and access tokens outlive their theft by at most their 10-minute lifetime.

What your app must still do

Sentinel removes tokens from the XSS blast radius; it cannot remove the XSS. The page-integrity work is still yours:

  • Ship a Content-Security-Policy. A strict CSP (no unsafe-inline, pinned script sources) is the strongest single control against injected script actually running.
  • Keep the CSRF cookie’s contract. The sentinel_csrf value is supposed to be script-readable — never “harden” it to httpOnly (the double-submit breaks), and never copy httpOnly semantics away by mirroring tokens into your own JS state, Redux store, or error-reporting breadcrumbs.
  • Audit the third-party scripts. Your dependencies run in your origin. Lockfiles, subresource integrity for CDN assets, and a short allowlist of third-party snippets matter more than any storage decision.
  • Remember: XSS can still use a session it cannot steal. With cookie transport, injected script can make authenticated same-origin requests while the tab lives. That’s contained (it ends when the page closes, and it’s loud in your server logs) but not nothing — CSP and step-up prompts for sensitive actions (passkeys, fresh MFA) are the remaining layer.
  • Serve over HTTPS with HSTS so Secure cookies mean what they say.