Access Tokens and Refresh Tokens
Understanding authentication and authorization mechanisms in web applications
Access Tokens and Refresh Tokens
Access Token & Refresh Token
Access Token
- A short-lived credential used to authenticate API requests.
- Attached to requests, usually in the
Authorization: Bearer <token>
header. - Lifetime is intentionally short (minutes to ~1 hour) to limit risk if stolen.
-
Commonly stored in in-memory state (e.g., Zustand store, React state).
sessionStorage
orlocalStorage
can be used, but those are more exposed to XSS attacks.
Refresh Token
- A long-lived credential used to obtain new access tokens when they expire.
- Typically has a longer lifetime (days to weeks).
- Because it can mint new access tokens, it must be protected carefully.
-
Usually stored in HttpOnly, Secure, SameSite cookies:
- HttpOnly → JavaScript cannot read it (protects against XSS).
- Secure → Sent only over HTTPS.
- SameSite → Mitigates CSRF attacks.
Token Lifecycle
- On login, the server issues both an Access Token (short-lived) and a Refresh Token (long-lived).
- The client sends the Access Token with each API request.
-
When the Access Token expires:
- The client calls a refresh endpoint with the Refresh Token (sent automatically via cookie).
- The server validates the Refresh Token and issues a new Access Token.
- If the Refresh Token has expired or is invalid → the user must log in again.
Implementation Example
authStore.ts
import { create } from "zustand";
interface AuthState {
accessToken: string | null;
setAccessToken: (token: string | null) => void;
}
export const useAuthStore = create<AuthState>((set) => ({
accessToken: null,
setAccessToken: (token) => set({ accessToken: token }),
}));
api.ts
import axios from "axios";
import { useAuthStore } from "./authStore";
const api = axios.create({
baseURL: "http://localhost:4000",
withCredentials: true, // include Refresh Token cookie
});
// Request interceptor: attach Access Token
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().accessToken;
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// Response interceptor: handle 401 and refresh flow
let isRefreshing = false;
let refreshQueue: (() => void)[] = [];
api.interceptors.response.use(
(res) => res,
async (err) => {
const originalRequest = err.config;
if (err.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
return new Promise((resolve) => {
refreshQueue.push(() => resolve(api(originalRequest)));
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const res = await axios.get("http://localhost:4000/auth/refresh", {
withCredentials: true,
});
const newToken = res.data.accessToken;
useAuthStore.getState().setAccessToken(newToken);
refreshQueue.forEach((cb) => cb());
refreshQueue = [];
return api(originalRequest);
} finally {
isRefreshing = false;
}
}
return Promise.reject(err);
}
);
export default api;
App.tsx
import { useEffect } from "react";
import api from "./api";
import { useAuthStore } from "./authStore";
function App() {
const setAccessToken = useAuthStore((s) => s.setAccessToken);
useEffect(() => {
// Attempt refresh at app start (cookie contains Refresh Token)
api.get("/auth/refresh").then((res) => {
setAccessToken(res.data.accessToken);
});
}, []);
return <div>My App</div>;
}
Security Considerations
- Access Token → short-lived, stored in memory.
- Refresh Token → long-lived, stored in HttpOnly + Secure + SameSite cookies.
- Always use HTTPS to avoid token sniffing.
- Rotate or invalidate refresh tokens on logout or suspicious activity.
- If refresh fails, force the user to log out.
- Handle concurrent refresh requests carefully (e.g., with a refresh queue).
Summary
- Access Token: short-lived, used for API authentication.
- Refresh Token: long-lived, used to renew access tokens.
- Access Token is typically stored in memory; Refresh Token is stored in secure cookies.
- Together, they enable secure session management with minimal user friction.