A single-page application moves your app's logic into the browser, and that shift rewrites your threat model. Two facts dominate everything else. First, your entire JavaScript bundle is public — anyone can open DevTools and read every line, so nothing secret can live in it. Second, in an SPA a single cross-site scripting flaw is not a bug, it is game over: injected script runs in the same context as your app, with access to whatever the app can reach, including auth tokens. Every other SPA security decision — where you store tokens, how you configure OAuth, what your CSP allows — is downstream of those two realities. This guide walks the choices that actually determine whether an SPA holds up.
Where should an SPA store its access token?
This is the most debated question in SPA security, and the honest answer is that both common options have a failure mode, so you optimize against the more dangerous one: XSS.
localStorage/sessionStorage: readable by any JavaScript on the page. One XSS and the attacker exfiltrates the token instantly. Convenient, and immune to CSRF, but catastrophic under XSS.HttpOnlycookies: not readable by JavaScript at all, so an XSS can't directly steal the token. The tradeoff is CSRF exposure, which you close withSameSite=Lax/Strictand anti-CSRF tokens.
The prevailing 2026 guidance for browser SPAs is to keep tokens in HttpOnly, Secure, SameSite cookies — often via a lightweight backend-for-frontend that holds the tokens server-side and exposes only a session cookie to the browser. That removes tokens from JavaScript's reach entirely. If you must use localStorage, keep access tokens short-lived and treat your XSS defenses as load-bearing.
Why is XSS uniquely fatal in an SPA?
Because there is no server-rendered page boundary to contain it. Injected script executes with the full authority of your app: it can read localStorage, call your APIs with the user's session, rewrite the DOM, and keylog forms. The defenses are the same as any modern frontend but the stakes are higher:
- Let your framework escape by default; sanitize the raw-HTML sinks (
dangerouslySetInnerHTML,v-html, Angular'sbypassSecurityTrust*) with DOMPurify. - Allowlist URL schemes on any
href/srcbuilt from data. - Deploy a strict Content-Security-Policy as the backstop.
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{random}';
object-src 'none'; base-uri 'self'; frame-ancestors 'none'
Avoid unsafe-inline — it defeats the policy. A strong CSP means that even if an injection lands, the browser refuses to execute the attacker's script.
How should an SPA do OAuth 2.0 login?
With the Authorization Code flow plus PKCE — never the deprecated Implicit flow. Because an SPA is a public client that can't hold a secret, PKCE (Proof Key for Code Exchange) protects the code exchange: the app generates a random code_verifier, sends its hash as the code_challenge on the authorization request, and proves possession of the verifier when redeeming the code. An intercepted authorization code is useless without the verifier.
// Generate the PKCE pair (Web Crypto)
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const challenge = base64url(
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))
);
// Send `challenge` on /authorize; keep `verifier` to redeem the code
Validate state to prevent CSRF on the callback, and register exact redirect URIs — never wildcards.
What CORS configuration is safe for an SPA API?
A tightly scoped one. CORS is not a security boundary that protects your data — it governs which origins the browser lets read your responses. The dangerous mistake is reflecting the request's Origin header back with Access-Control-Allow-Credentials: true, which effectively allows every site to make credentialed calls. Allowlist your exact frontend origins, and never combine a wildcard * origin with credentials.
What must never appear in the bundle?
Any real secret. API keys for third-party services that carry privileges, private tokens, and internal-only endpoints are all readable in a shipped bundle. Client-side environment variables (VITE_*, NEXT_PUBLIC_*, REACT_APP_*) are configuration, not secrets — anything sensitive belongs on a backend the SPA calls. Assume an attacker has read your entire bundle, because they have.
SPA security checklist
| Decision | Secure choice |
|---|---|
| Token storage | HttpOnly/Secure/SameSite cookies (ideally via a BFF) |
| XSS | Framework escaping + DOMPurify on raw-HTML sinks |
| Script execution | Strict, nonce-based CSP (no unsafe-inline) |
| OAuth | Authorization Code + PKCE, validate state |
| CORS | Exact origin allowlist; never * with credentials |
| Secrets | None in the bundle; keep on a backend |
| Dependencies | Continuous SCA on the full tree |
The bundle is built from dependencies
Everything you ship is compiled from npm packages, and 2025 showed that supply chain is actively hostile — the Shai-Hulud worm compromised 500+ packages (CISA, September 2025) and trojanized chalk and debug. A poisoned build dependency can inject data-exfiltration code straight into your public bundle. Pair the runtime controls above with software composition analysis across the full dependency graph.
How Safeguard Helps
Safeguard resolves your SPA's complete dependency tree, flags malicious and typosquatted packages before a build consumes them, and uses reachability analysis to focus you on the CVEs your code truly runs. Griffin, our AI engine, reviews new versions for the behavioral tells behind the 2025 npm attacks, and auto-fix opens tested pull requests with the minimal safe bump. A dynamic scan confirms your CSP, CORS, and auth flows behave in the running app. Token storage, PKCE, and CSP are yours to get right — Safeguard secures the dependencies your bundle is built from.
Ship a secure SPA — get started free, read the documentation, or see how Safeguard compares.