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.
- Capability
- /docs/authentication/

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_csrfvalue 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
Securecookies mean what they say.