const STORAGE_KEY = "auth.refreshToken";
let accessToken = null;
let refreshToken = localStorage.getItem(STORAGE_KEY);
const listeners = new Set();
function emit(event) {
for (const fn of listeners) fn(event);
}
export const tokenStore = {
getAccessToken() {
return accessToken;
},
getRefreshToken() {
return refreshToken;
},
setTokens({ access, refresh }) {
accessToken = access;
if (refresh) {
refreshToken = refresh;
localStorage.setItem(STORAGE_KEY, refresh);
}
emit("set");
},
clear() {
accessToken = null;
refreshToken = null;
localStorage.removeItem(STORAGE_KEY);
emit("clear");
},
subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
};
import axios from "axios";
import { tokenStore } from "./tokenStore";
const BASE_URL = "/api";
export const authClient = axios.create({ baseURL: BASE_URL });
let refreshPromise = null;
function refreshAccessToken() {
if (!refreshPromise) {
const token = tokenStore.getRefreshToken();
if (!token) return Promise.reject(new Error("no_refresh_token"));
refreshPromise = axios
.post(`${BASE_URL}/auth/refresh`, { refreshToken: token })
.then((res) => {
tokenStore.setTokens({
access: res.data.accessToken,
refresh: res.data.refreshToken,
});
return res.data.accessToken;
})
.finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
authClient.interceptors.request.use((config) => {
const token = tokenStore.getAccessToken();
if (token) {
config.headers = config.headers || {};
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
authClient.interceptors.response.use(
(response) => response,
async (error) => {
const original = error.config;
const status = error.response && error.response.status;
if (status !== 401 || !original || original._retry) {
return Promise.reject(error);
}
original._retry = true;
try {
const newToken = await refreshAccessToken();
original.headers.Authorization = `Bearer ${newToken}`;
return authClient(original);
} catch (refreshError) {
tokenStore.clear();
return Promise.reject(refreshError);
}
}
);
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { tokenStore } from "./tokenStore";
export function useAuthGuard() {
const navigate = useNavigate();
useEffect(() => {
if (!tokenStore.getRefreshToken()) {
navigate("/login", { replace: true });
}
const unsubscribe = tokenStore.subscribe((event) => {
if (event === "clear") {
navigate("/login", { replace: true });
}
});
return unsubscribe;
}, [navigate]);
}
This snippet shows how an SPA can refresh expired access tokens transparently, so callers never have to think about 401 responses or token lifetimes. The core pattern is a response interceptor that catches an expired-token error, obtains a fresh access token, and replays the original request — while making sure that a burst of concurrent requests triggers only a single refresh call (a "single-flight" refresh).
The tokenStore tab is a small module that owns all token state. It keeps the access and refresh tokens in memory and mirrors the refresh token to localStorage so a page reload can bootstrap a session. Keeping the access token in memory only is a deliberate trade-off: it reduces the XSS blast radius while accepting that a hard refresh needs a silent re-auth. The store exposes getAccessToken, setTokens, and clear, giving the rest of the app one place to read from and one place to invalidate on logout.
The authClient tab is where the interesting concurrency logic lives. A request interceptor stamps every outgoing call with the current bearer token. The response interceptor inspects failures: only a genuine 401 on a request that has not already been retried is eligible for recovery. The key detail is refreshPromise — a module-level promise that acts as a lock. The first 401 calls refreshAccessToken, which stores the in-flight promise; every other request that fails during that window awaits the same promise instead of firing its own refresh. This prevents a thundering herd of refresh calls that would race, rotate the refresh token multiple times, and invalidate each other. Note that the refresh request uses a bare axios instance, not authClient, to avoid an infinite interceptor loop. On success the original config is replayed with the new token; on failure the store is cleared and the error propagates so the UI can redirect to login.
The useAuthGuard hook tab shows the consumer side. React components simply call authClient and stay oblivious to token mechanics; the hook only reacts when a refresh ultimately fails, listening for a clear event to bounce the user to /login. This separation is the whole point: retry-and-refresh policy is centralized in one interceptor, so feature code reads like ordinary HTTP calls. Watch for pitfalls the code guards against — marking _retry to avoid endless loops, and resetting refreshPromise in a finally so a failed refresh does not permanently wedge the client.
Related snips
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
use crossbeam::channel::unbounded;
use std::thread;
fn main() {
let (tx, rx) = unbounded();
Crossbeam for advanced concurrent data structures
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
Channels (mpsc) for message passing between threads
Share this code
Here's the card — post it anywhere.