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

A hands-on, example-packed guide to storing and refreshing JWTs safely in frontend apps: in-memory access tokens, HttpOnly refresh cookies, rotation, CORS/CSRF, and real code you can ship. - 10 Sept 2025

4 min read

views

Cover illustration for JWT storage best practices in frontend apps

🍪 JWTs in the Frontend: Do’s, Don’ts, and “Oh No!”s

So you’ve got JWTs in your app. Great! But how and where you store them can be 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.

Security tip: 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.