OAuth2 Token Security: Preventing XSS-Based Theft in SPAs
OAuth2 token security is critical for SPAs. Discover why local storage is a liability and how to use HttpOnly cookies to stop XSS-based token theft.
While refactoring our frontend authentication flow last month, I noticed a junior developer storing an access token directly in localStorage. It’s a common mistake, but it leaves your entire application wide open to XSS-based token theft. If an attacker can inject a script into your page, they can pull that token out with a single line of code and impersonate your user until the token expires.
When we talk about OAuth2 token security, we have to acknowledge that the browser is a hostile environment. If you're building a Single-Page Application (SPA), you're likely dealing with complex dependency trees where a single compromised npm package could lead to a full account takeover.
Why Local Storage is a Security Anti-Pattern
I used to think localStorage was fine because "we have a strong Content Security Policy (CSP)." That’s a dangerous assumption. If your application has any vulnerability—like a reflected XSS vector—your CSP might not catch a sophisticated attacker who knows how to bypass it. Once they run localStorage.getItem('access_token'), your security architecture effectively collapses.
We previously looked at OAuth2 Security: Preventing Token Exchange Vulnerabilities to secure the handshake, but even a perfect handshake is useless if the final token is sitting in a public browser bucket.
The Backend-for-Frontend (BFF) Solution
The most effective way to handle SPA security is to stop giving the frontend raw access to the token. Instead of the SPA holding the token, we use a Backend-for-Frontend (BFF).
In this pattern, the SPA talks to a lightweight backend (often a small Node.js or Go proxy). This backend handles the OAuth2 dance and stores the tokens in an HttpOnly, Secure, and SameSite=Strict cookie.
Comparing Token Storage Strategies
| Method | XSS Risk | Implementation Complexity | Best For |
|---|---|---|---|
| LocalStorage | High | Low | Low-security prototypes |
| SessionStorage | Moderate | Low | Temporary session state |
| HttpOnly Cookie | Low | High | Production SPAs |
By moving the token to an HttpOnly cookie, JavaScript literally cannot access it. Even if an attacker successfully executes a malicious script on your page, they cannot read the cookie. They might be able to make requests on behalf of the user, but they cannot exfiltrate the token to their own server.
Mitigating XSS Protection Gaps
Of course, moving tokens to cookies doesn't mean you can ignore XSS protection. You still need to defend your application layers. Even if the token is safe, an attacker could still perform actions on behalf of the user if your app is vulnerable to CSRF or XSS.
Before you finalize your auth flow, ensure you've reviewed Cross-Site Scripting Mitigation: Securing Dynamic WordPress UIs to understand how to sanitize your inputs effectively. I also recommend checking OAuth2 PKCE Implementation: Preventing Common Auth Vulnerabilities to ensure your initial code exchange isn't the weak link in your chain.
A Simple BFF Flow
Sequence diagram: participant SPA; participant BFF; participant IDP; SPA → BFF: Login Request; BFF → IDP: OAuth2 Authorization Code Flow; IDP → BFF: Access Token & Refresh Token; BFF → SPA: Set-Cookie HttpOnly; SPA → BFF: API Request; BFF → BFF: Validate Cookie; BFF → API: Forward Request with Bearer Token
Implementation Caveats
When we switched to this model, we ran into a few snags. First, managing CSRF tokens becomes mandatory because browsers automatically attach cookies to requests. You'll need to implement a custom header (like X-Requested-With or a double-submit cookie) to verify that the request originated from your SPA and not a malicious site.
Also, remember that SameSite=Strict is great for security but can break your user experience if you use an external Identity Provider that redirects back to your site. You might need to use SameSite=Lax for the callback route, which is a trade-off I'm still not 100% comfortable with, but it's often necessary for cross-domain OAuth2 flows.
Frequently Asked Questions
Q: Can I use Refresh Token Rotation instead of a BFF?
A: Refresh token rotation adds a layer of safety, but it doesn't solve the core issue of token exfiltration via XSS. If you store the refresh token in localStorage, it's still vulnerable. See JWT Security: Preventing Improper Refresh Token Rotation Risks for more on why rotation alone isn't a silver bullet.
Q: Does HttpOnly prevent all token theft? A: It prevents the reading of the token via JavaScript. However, an XSS vulnerability still allows an attacker to perform actions as the user. You must pair cookie-based storage with rigorous input sanitization and a strict CSP.
I'm still tinkering with our BFF implementation to reduce latency—currently, the overhead is about 15ms per request, which is negligible for our use case, but I'm keeping an eye on it. If you're building a high-traffic app, make sure to profile your proxy layer before rolling it out to production.