Skip to content
← Writing

Stop putting JWTs in localStorage: a fun, practical guide for frontend apps

Storing and refreshing JWTs safely in a frontend app: in-memory access tokens, HttpOnly refresh cookies, rotation, and the CORS and CSRF details.

  • 3 min read
Skip to contents
Contents
Cover illustration for JWT storage best practices in frontend apps

So you’ve got JWTs in your app. How and where you store them is the difference between “Secure app” and “Oops, we leaked tokens on Pastebin.”


Don’t: Put JWTs in localStorage

// BAD PRACTICE
localStorage.setItem('token', accessToken);
  • Why it’s bad:

    • XSS = game over. Any malicious script can localStorage.getItem('token').
    • Tokens persist across refreshes and tab closings, which makes them a juicy target.
    • No easy way to log users out across tabs.

Imagine: You proudly ship your SPA. One day a marketing script has a sneaky bug, and suddenly attackers are sipping your users’ tokens like iced lattes.


Do: Keep Access Tokens in Memory

let accessToken: string | null = null;
 
export function setAccessToken(token: string) {
  accessToken = token;
}
 
export function getAccessToken() {
  return accessToken;
}
  • Short-lived (5-15 min).
  • Wiped out when you refresh or close tab.
  • If stolen, attacker has very little time to use it.

Do: Put Refresh Tokens in HttpOnly Cookies

Server sets it like:

res.cookie('refresh_token', refreshToken, {
  httpOnly: true,
  secure: true,
  sameSite: 'lax',
  path: '/auth/refresh',
  maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
});
  • HttpOnly → JavaScript can’t touch it
  • Secure → only over HTTPS
  • SameSite → reduces CSRF

Note on SameSite:

  • Use lax for most SPAs served from the same site or subdomain.
  • Use strict if your app never needs cross-site navigation with cookies (most restrictive).
  • Use none + secure: true if your API is on a different site (not just a subdomain) and you need cross-site requests.

Rotate the refresh token on every refresh and detect reuse server-side. Invalidate the old token so a stolen one can’t be replayed.


After Refreshing the Tab

  • Access token in memory? Gone. Poof.
  • Refresh token cookie? Still there, browser keeps it.

So on app boot:

// bootstrap.ts
try {
  const res = await axios.post('/auth/refresh', null, { withCredentials: true });
  setAccessToken(res.data.accessToken);
} catch {
  setAccessToken(null);
  redirectToLogin();
}

Result: User stays logged in like nothing happened.


Pitfalls & How to Avoid Them

PitfallWhy it’s badFix
Long-lived Access TokensIf stolen, attacker gets days of free accessKeep AT short (≤15 min)
Refresh Token in JS storageEquivalent to leaving your house key under the matAlways cookie (HttpOnly + Secure + SameSite)
Wildcard CORS (*) with credentialsAny site could make requests with your cookiesStrictly whitelist origins
Not rotating refresh tokensIf one leaks, attacker reuses foreverRotate RT on every refresh
Ignoring CSRFIf cookies auto-send on cross-site POSTAdd CSRF tokens for cookie-based auth endpoints

Example “Do and Don’t” Cheatsheet

Do this:

// Create an axios instance with cookies enabled
const api = axios.create({ withCredentials: true });
 
let isRefreshing = false;
let pendingRequests = [] as Array<(token?: string) => void>;
 
api.interceptors.request.use((config) => {
  const token = getAccessToken();
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});
 
api.interceptors.response.use(undefined, async (error) => {
  const status = error.response?.status;
  const original = error.config || {};
 
  // Don't try to refresh if the refresh endpoint itself failed
  const isRefreshCall = typeof original.url === 'string' && original.url.includes('/auth/refresh');
 
  if (status === 401 && !original._retry && !isRefreshCall) {
    (original as any)._retry = true;
 
    if (isRefreshing) {
      return new Promise((resolve) => {
        pendingRequests.push((newToken) => {
          if (newToken) {
            original.headers = { ...original.headers, Authorization: `Bearer ${newToken}` };
          }
          resolve(api(original));
        });
      });
    }
 
    isRefreshing = true;
    try {
      const { data } = await api.post('/auth/refresh', null); // cookie sent automatically
      setAccessToken(data.accessToken);
      pendingRequests.forEach((cb) => cb(data.accessToken));
      pendingRequests = [];
      original.headers = { ...original.headers, Authorization: `Bearer ${data.accessToken}` };
      return api(original);
    } catch (e) {
      pendingRequests.forEach((cb) => cb());
      pendingRequests = [];
      setAccessToken(null);
      redirectToLogin();
      return Promise.reject(e);
    } finally {
      isRefreshing = false;
    }
  }
 
  return Promise.reject(error);
});

Don’t do this:

// Storing in localStorage
const token = localStorage.getItem('accessToken');
fetch('/api/user', { headers: { Authorization: `Bearer ${token}` } });

Extra Defenses

  • Content-Security-Policy → block inline JS.
  • Trusted Types (Chrome) → block XSS sinks.
  • Don’t put tokens in URLs → they end up in logs & analytics.
  • BroadcastChannel → cross-tab logout sync.
  • security
  • jwt
  • frontend
  • web-development
  • authentication

Comments