October 28, 2026
Stop Storing JWTs in localStorage: React Auth with Refresh Tokens and httpOnly Cookies
Most React auth tutorials, including some of my older ones, save the JWT in localStorage. It's easy, and it's risky. Any script that runs…

By Chirag Mehta
2 min read
Most React auth tutorials, including some of my older ones, save the JWT in localStorage. It's easy, and it's risky. Any script that runs on your page, including one injected through an XSS bug or a compromised npm package, can read it.
Here's the setup I use now.
The plan
- Access token: lives for 15 minutes, stored in React memory (a plain variable). Only your app can read it, and it's gone on page refresh.
- Refresh token: lives for 7 days, stored in an httpOnly, Secure cookie. Only the server can read it.
When the access token expires, the app quietly asks the server for a new one. The browser sends the refresh cookie automatically.
Server side: set the cookie
In your Express login route, after checking the password:
res.cookie("refresh_token", refreshToken, {
httpOnly: true, // JavaScript can't read it
secure: true, // HTTPS only
sameSite: "strict", // not sent on cross-site requests
path: "/api/auth", // only sent to refresh and logout
maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.json({ accessToken });res.cookie("refresh_token", refreshToken, {
httpOnly: true, // JavaScript can't read it
secure: true, // HTTPS only
sameSite: "strict", // not sent on cross-site requests
path: "/api/auth", // only sent to refresh and logout
maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.json({ accessToken });Setting path means the cookie only travels to the auth routes that need it.
Client side: keep the access token in memory
// src/auth/token.ts
let accessToken: string | null = null;
export const getToken = () => accessToken;
export const setToken = (t: string | null) => {
accessToken = t;
};// src/auth/token.ts
let accessToken: string | null = null;
export const getToken = () => accessToken;
export const setToken = (t: string | null) => {
accessToken = t;
};A fetch wrapper that refreshes on 401
// src/api/client.ts
import { getToken, setToken } from "../auth/token";
let refreshing: Promise<string | null> | null = null;
async function refreshToken(): Promise<string | null> {
const res = await fetch("/api/auth/refresh", {
method: "POST",
credentials: "include",
});
if (!res.ok) return null;
const { accessToken } = await res.json();
setToken(accessToken);
return accessToken;
}
export async function api(input: string, init: RequestInit = {}) {
const withAuth = (token: string | null): RequestInit => ({
...init,
credentials: "include",
headers: { ...init.headers, ...(token ? { Authorization: `Bearer ${token}` } : {}) },
});
let res = await fetch(input, withAuth(getToken()));
if (res.status !== 401) return res;
// Share one refresh call between parallel requests
refreshing ??= refreshToken().finally(() => (refreshing = null));
const newToken = await refreshing;
if (!newToken) {
window.location.href = "/login";
return res;
}
return fetch(input, withAuth(newToken));
}// src/api/client.ts
import { getToken, setToken } from "../auth/token";
let refreshing: Promise<string | null> | null = null;
async function refreshToken(): Promise<string | null> {
const res = await fetch("/api/auth/refresh", {
method: "POST",
credentials: "include",
});
if (!res.ok) return null;
const { accessToken } = await res.json();
setToken(accessToken);
return accessToken;
}
export async function api(input: string, init: RequestInit = {}) {
const withAuth = (token: string | null): RequestInit => ({
...init,
credentials: "include",
headers: { ...init.headers, ...(token ? { Authorization: `Bearer ${token}` } : {}) },
});
let res = await fetch(input, withAuth(getToken()));
if (res.status !== 401) return res;
// Share one refresh call between parallel requests
refreshing ??= refreshToken().finally(() => (refreshing = null));
const newToken = await refreshing;
if (!newToken) {
window.location.href = "/login";
return res;
}
return fetch(input, withAuth(newToken));
}The refreshing promise matters. If five requests fail at once, you want one refresh call, not five.
Restore the session on page load
Because the access token lives in memory, a page reload clears it. Call refresh once when the app starts:
useEffect(() => {
refreshToken().then((token) => {
if (token) loadCurrentUser();
setReady(true);
});
}, []);useEffect(() => {
refreshToken().then((token) => {
if (token) loadCurrentUser();
setReady(true);
});
}, []);Show a loader until ready is true, so your protected routes don't redirect to login too early.
Logout
Clear memory and ask the server to clear the cookie and revoke the refresh token:
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
setToken(null);await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
setToken(null);Checklist
- No tokens in
localStorageorsessionStorage - Refresh cookie is
httpOnly,secureandsameSite - One shared refresh call for parallel 401s
- Refresh on app load
- The server rotates and can revoke refresh tokens
Pair this with the protected route guard from my React Router v7 guide and you have a complete, safer auth flow.