May 26, 20267 min

Stop Storing JWTs in LocalStorage: Cookie Auth for SPAs in 2026

A practical 2026 guide to moving JWT authentication out of LocalStorage and into HTTP-only cookies with CSRF protection for SPA, SSR, upload, WebSocket, gateway, and mobile scenarios.

When a JWT lives in localStorage, any injected script can read it and send it away. That is the uncomfortable part of many SPA authentication setups: the token is convenient for JavaScript, but convenience is exactly what makes it easy to steal after an XSS bug.

The better default in 2026 is to keep sensitive tokens out of JavaScript and let the browser handle transport through HTTP-only cookies.

Put the same token in an HTTP-only cookie and three useful things happen:

  • JavaScript cannot read it, so injected scripts lose the easiest token theft path.
  • The browser sends it automatically with matching requests.
  • Cookie flags such as Secure, HttpOnly, and SameSite add protection against sniffing and some CSRF patterns.

This does not remove all authentication risk. You still need CSRF protection for unsafe methods, short-lived access tokens, refresh rotation, and careful logout behavior. But it is a stronger baseline than placing bearer tokens in browser storage.

Server Side: Issuing Tokens with Node.js

Here is a minimal Express-style setup.

npm install express cookie-parser jsonwebtoken
import express from 'express';
import cookieParser from 'cookie-parser';

const app = express();

app.use(express.json());
app.use(cookieParser());

For production, I usually define shared cookie options once:

const isProduction = process.env.NODE_ENV === 'production';

const authCookie = {
  httpOnly: true,
  secure: isProduction,
  sameSite: 'strict',
  path: '/',
};

httpOnly keeps the token away from JavaScript. secure ensures the cookie is sent only over HTTPS in production. sameSite: 'strict' is a conservative default for same-site apps.

If your frontend and API are on different sites, you may need sameSite: 'none' plus secure: true, and you must configure CORS carefully.

Login Endpoint

After a successful credential check, create an access token and a refresh token. The access token should be short-lived. The refresh token can live longer, but it should still be rotated and revocable.

import jwt from 'jsonwebtoken';

app.post('/api/token', async (req, res) => {
  const { username, password } = req.body;

  // 1. Validate credentials here.
  // 2. Load the user.
  // 3. Reject inactive or locked accounts.

  const accessToken = jwt.sign(
    { sub: username },
    process.env.ACCESS_SECRET,
    { expiresIn: '5m' },
  );

  const refreshToken = jwt.sign(
    { sub: username },
    process.env.REFRESH_SECRET,
    { expiresIn: '1d' },
  );

  res
    .cookie('access_token', accessToken, {
      ...authCookie,
      maxAge: 5 * 60 * 1000,
    })
    .cookie('refresh_token', refreshToken, {
      ...authCookie,
      maxAge: 24 * 60 * 60 * 1000,
    })
    .json({ user: username, status: 'active' });
});

The client receives user-safe information in JSON. The sensitive tokens stay in cookies that frontend JavaScript cannot read.

It is still fine to store public UI state such as username, theme, or feature flags in localStorage. The important rule is: do not store bearer tokens there.

Refresh Endpoint

The refresh endpoint reads the refresh token from cookies. If the token is valid, it issues a new pair.

app.post('/api/token/refresh', (req, res) => {
  const refreshToken = req.cookies.refresh_token;

  if (!refreshToken) {
    return res.sendStatus(401);
  }

  jwt.verify(refreshToken, process.env.REFRESH_SECRET, (err, decoded) => {
    if (err) {
      return res.sendStatus(403);
    }

    const newAccess = jwt.sign(
      { sub: decoded.sub },
      process.env.ACCESS_SECRET,
      { expiresIn: '5m' },
    );

    const newRefresh = jwt.sign(
      { sub: decoded.sub },
      process.env.REFRESH_SECRET,
      { expiresIn: '1d' },
    );

    res
      .cookie('access_token', newAccess, {
        ...authCookie,
        maxAge: 5 * 60 * 1000,
      })
      .cookie('refresh_token', newRefresh, {
        ...authCookie,
        maxAge: 24 * 60 * 60 * 1000,
      })
      .json({ ok: true });
  });
});

In a real production system, also store a refresh token identifier server-side so you can revoke old refresh tokens, detect reuse, and invalidate sessions after logout.

CSRF Protection Still Matters

HTTP-only cookies reduce XSS token theft, but they do not remove CSRF risk. The browser automatically sends cookies, including on some unwanted cross-site requests.

For unsafe methods such as POST, PUT, PATCH, and DELETE, require a CSRF token.

One common pattern is:

  1. Server issues a non-HTTP-only CSRF token cookie or returns a CSRF token from a dedicated endpoint.
  2. Client reads that CSRF value.
  3. Client sends it back in an X-CSRF-Token header.
  4. Server verifies that the header matches the expected value.
app.get('/api/csrf-token', (req, res) => {
  const token = crypto.randomUUID();

  res
    .cookie('csrf_token', token, {
      secure: isProduction,
      sameSite: 'strict',
      path: '/',
    })
    .set('X-CSRF-Token', token)
    .json({ ok: true });
});

You can implement this manually, use framework support, or use a maintained CSRF package. The key idea is the same: unsafe requests must prove that they came from your frontend code, not from a random form post on another site.

React Client: Login with Cookies and CSRF

The client does not manually handle JWTs anymore. It asks the browser to include cookies by setting credentials: 'include'.

async function getCSRF() {
  const response = await fetch('/api/csrf-token', {
    credentials: 'include',
  });

  return response.headers.get('x-csrf-token');
}

Then use that token during login:

const loginUser = async (event) => {
  event.preventDefault();

  const csrf = await getCSRF();

  const response = await fetch('/api/token', {
    method: 'POST',
    credentials: 'include',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-Token': csrf,
    },
    body: JSON.stringify({
      username: event.target.username.value,
      password: event.target.password.value,
    }),
  });

  if (response.ok) {
    const data = await response.json();
    localStorage.setItem('username', data.user);
    return;
  }

  alert('Wrong login or password');
};

Notice the separation:

  • cookies hold sensitive tokens,
  • localStorage holds only harmless display information,
  • the CSRF token is sent as a header for unsafe requests.

Auto-Refreshing the Access Token

If access tokens expire after five minutes, refresh slightly before the deadline.

useEffect(() => {
  const timer = setInterval(async () => {
    const csrf = await getCSRF();

    await fetch('/api/token/refresh', {
      method: 'POST',
      credentials: 'include',
      headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': csrf,
      },
    });
  }, 4.5 * 60 * 1000);

  return () => clearInterval(timer);
}, []);

Users keep working without seeing constant "session expired" popups, while the access token remains short-lived.

You can also refresh reactively: if an API call returns 401, call /api/token/refresh once, then retry the original request.

Five Practical Scenarios

Cookie-based auth changes how a few common workflows look.

1. Uploading Large Files

Because cookies travel automatically, upload code stays clean.

const uploadFile = async (file) => {
  const data = new FormData();
  data.append('file', file);

  await fetch('/api/upload', {
    method: 'POST',
    body: data,
    credentials: 'include',
  });
};

You still need CSRF protection for uploads if they mutate server state.

2. Server-Side Rendering with Next.js

During SSR, the access token is available in request cookies.

export async function getServerSideProps({ req }) {
  const token = req.cookies.access_token;

  // Fetch data on behalf of the user.

  return {
    props: {},
  };
}

That lets you pre-fetch private data and return completed HTML without exposing the token to browser JavaScript.

3. Real-Time Chat over WebSocket

For same-site WebSocket connections, browsers usually include cookies during the WebSocket handshake.

On the server, validate the cookie and check the Origin header before accepting the connection.

If your infrastructure strips cookies or you need cross-site WebSocket auth, avoid putting long-lived JWTs in the URL. URLs can leak into logs. Prefer a short-lived, one-time connection ticket.

4. API Gateway in Front of Microservices

An API gateway can read the cookie, verify the token, and forward trusted identity information to internal services.

Another option is to copy the token into an internal Authorization header after validation.

The inner services stay independent from browser details. They either trust the gateway or verify a service-facing token.

5. Mobile Builds with React Native

React Native is different. Mobile apps do not have browser-enforced HTTP-only cookies in the same way.

Use the platform secure storage instead:

  • Keychain on iOS,
  • Keystore on Android,
  • a maintained secure storage wrapper.

That is the closest mobile equivalent: store refresh credentials somewhere encrypted and isolated from other apps.

Takeaways

HTTP-only cookies are not a magic shield, but they are a better default for SPA authentication than localStorage bearer tokens.

The practical checklist:

  • keep JWTs out of localStorage,
  • use HTTP-only cookies for access and refresh tokens,
  • set Secure, HttpOnly, and SameSite deliberately,
  • protect unsafe methods with CSRF tokens,
  • keep access tokens short-lived,
  • rotate refresh tokens,
  • revoke sessions server-side when users log out,
  • validate origins for WebSocket flows.

The move is mostly wiring. The payoff is meaningful: fewer ways for injected JavaScript to steal user identity, cleaner client code, and a security model that better matches how browsers actually work.