Refresh Token trên Web: Bài toán thực tế, cơ chế và triển khai production
Ngày 25 tháng 3, 2026
Trong production, lỗi auth thường không nằm ở bước đăng nhập, mà nằm ở duy trì phiên đăng nhập:
- tự nhiên bị logout sau vài phút,
- nhiều tab ghi đè token của nhau,
- vòng lặp refresh vô hạn,
- refresh token bị replay.
Bài viết này đi từ nhu cầu bài toán thực tế đến cách triển khai refresh token an toàn trên web.
1. Tại sao cần Refresh Token?
Access token nên có thời gian sống ngắn để giảm rủi ro bảo mật. Nếu token bị lộ, mức độ thiệt hại sẽ bị giới hạn theo TTL.
Nhưng nếu TTL quá ngắn mà không có cơ chế bù, người dùng sẽ bị out phiên liên tục.
Vì vậy ta tách vai trò:
- Access token: sống ngắn, dùng để gọi API.
- Refresh token: sống dài hơn, chỉ dùng để xin access token mới.
Client State
Access token expires quickly.
Refresh token lives longer.
Access Token TTL
8s
Refresh Action
Nhu cầu thực tế mà refresh token giải quyết
- Giữ UX mượt, không bắt đăng nhập lại liên tục.
- Tăng bảo mật so với access token sống dài.
- Hỗ trợ revoke phiên tập trung ở backend.
TTL là gì và nên chọn như thế nào?
TTL là viết tắt của Time to Live, tức thời gian token còn hiệu lực kể từ lúc được phát hành.
Gợi ý thực tế:
- Access token TTL ngắn (thường 5-15 phút) để giảm blast radius nếu bị lộ.
- Refresh token TTL dài hơn (vài ngày đến vài tuần) nhưng luôn có cơ chế revoke/rotation.
- TTL không phải càng dài càng tốt, mà phải cân bằng giữa UX và mức độ rủi ro của nghiệp vụ.
Best practice: refresh sớm trước hạn 10-30 giây
Trong production, không nên đợi access token hết hạn rồi mới refresh.
Nên làm proactive refresh trước exp khoảng 10-30s để tránh:
- request đang bay bị dính
401giữa chừng, - sai lệch giờ giữa client và server (clock skew),
- độ trễ mạng khiến token vừa hết hạn ngay lúc request đến server.
Công thức hay dùng:
refreshAt = exp - skewBuffer - jitter
skewBuffer: thường 10-30 giây,jitter: ngẫu nhiên 0-5 giây để tránh đồng loạt refresh cùng lúc.
Ví dụ scheduler trong React:
import { useEffect, useRef } from "react";
type SchedulerParams = {
accessTokenExp: number; // epoch seconds
refresh: () => Promise<void>;
onRefreshError: () => void;
};
export function useProactiveRefresh({
accessTokenExp,
refresh,
onRefreshError,
}: SchedulerParams) {
const timerRef = useRef<number | null>(null);
useEffect(() => {
const skewBufferMs = 20_000; // 10-30s la hop ly, o day chon 20s
const jitterMs = Math.floor(Math.random() * 5_000);
const expMs = accessTokenExp * 1000;
const refreshAt = expMs - skewBufferMs - jitterMs;
const delay = Math.max(refreshAt - Date.now(), 0);
timerRef.current = window.setTimeout(async () => {
try {
await refresh();
} catch {
onRefreshError();
}
}, delay);
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, [accessTokenExp, refresh, onRefreshError]);
}Lưu ý: proactive refresh không thay thế hoàn toàn reactive refresh theo 401; bạn vẫn nên giữ cả hai để hệ thống bền hơn.
2. Cơ chế chuẩn: Login -> Hết hạn access -> Refresh -> Retry
Luồng cơ bản trong web app:
- User login thành công.
- Server trả access token + refresh token.
- Client gọi API kèm access token.
- Access token hết hạn, API trả
401. - Client gọi
/auth/refresh. - Server xác minh refresh token và trả access token mới.
- Client retry request thất bại trước đó.
Nguyên tắc quan trọng
Endpoint refresh chỉ nên làm đúng một việc: gia hạn phiên. Không trộn business logic khác vào endpoint này.
3. Refresh Token Rotation: nên dùng trong production
Nếu không rotation, refresh token bị lộ có thể bị dùng lại trong thời gian dài.
Nếu có rotation:
- mỗi lần refresh phát hành refresh token mới,
- refresh token cũ bị vô hiệu ngay,
- backend theo dõi token family để phát hiện replay.
Old Token
rtk_v1
Marked as invalid after rotation
New Token
rtk_v2
Only latest refresh token is accepted
Lợi ích
- Giảm cửa sổ replay attack.
- Phát hiện hành vi bất thường sớm hơn.
- Dễ khóa toàn bộ family token khi nghi ngờ compromise.
4. Lưu token ở đâu trên web?
Đây là điểm ảnh hưởng trực tiếp tới mức độ an toàn của hệ thống.
Access in Memory
Least persistence, safer against casual token leak.
Refresh in HttpOnly Cookie
Preferred: hidden from JS and sent securely by browser.
LocalStorage
Convenient but vulnerable to XSS token exfiltration.
Cách làm phổ biến
- Access token giữ trong memory.
- Refresh token để trong cookie
HttpOnly + Secure + SameSite. - Hạn chế tối đa việc để refresh token lộ ra JavaScript runtime.
Vì sao tránh localStorage cho refresh token?
Nếu dính XSS, token trong localStorage có thể bị đọc và gửi ra ngoài rất nhanh.
5. Race-condition khi nhiều request cùng dính 401
Khi access token vừa hết hạn, nhiều request có thể fail cùng lúc.
Nếu xử lý sai:
- mỗi request đều tự gọi refresh,
- gây storm vào auth service,
- token mới/cũ ghi đè lẫn nhau.
Nếu xử lý đúng:
- dùng cơ chế single-flight,
- chỉ 1 refresh request được phép chạy,
- các request còn lại chờ kết quả rồi replay.
Pending requests
Refresh controller
Mode: Single-flight enabled
Keep one refresh request only, queue the rest to avoid token overwrite.
Ý tưởng triển khai single-flight
- Nếu đang có
refreshPromisethì await promise đó. - Nếu chưa có thì tạo mới refreshPromise.
- Cuối cùng luôn clear refreshPromise để tránh dead state.
6. Checklist validate ở backend cho endpoint refresh
Nên kiểm tra tối thiểu:
- chữ ký token và thời gian hết hạn,
- token còn active trong session store,
- rotation version/token family hợp lệ,
- tần suất gọi bất thường,
- metadata thiết bị/IP (tùy mức độ bảo mật).
Và khi logout cần revoke session ở server, không chỉ xóa state ở client.
7. Triển khai từ đầu ở client với React (fetch)
Ví dụ dưới đây dùng fetch và cơ chế single-flight để đảm bảo chỉ có 1 refresh request chạy tại một thời điểm.
FETCH REFRESH FLOW
import { useMemo } from "react";
type ApiClient = {
get: (url: string, init?: RequestInit) => Promise<Response>;
};
export function useApiClient(getAccessToken: () => string | null): ApiClient {
const client = useMemo(() => {
let refreshPromise: Promise<void> | null = null;
const refreshAccessToken = async () => {
const res = await fetch("/auth/refresh", {
method: "POST",
credentials: "include",
});
if (!res.ok) {
throw new Error("Refresh failed");
}
};
const runRefreshSingleFlight = async () => {
if (!refreshPromise) {
refreshPromise = refreshAccessToken().finally(() => {
refreshPromise = null;
});
}
await refreshPromise;
};
const request = async (url: string, init: RequestInit = {}) => {
const token = getAccessToken();
const res = await fetch(url, {
...init,
credentials: "include",
headers: {
...(init.headers || {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
if (res.status !== 401) {
return res;
}
await runRefreshSingleFlight();
const retriedToken = getAccessToken();
return fetch(url, {
...init,
credentials: "include",
headers: {
...(init.headers || {}),
...(retriedToken ? { Authorization: `Bearer ${retriedToken}` } : {}),
},
});
};
return {
get: (url, init) => request(url, { ...init, method: "GET" }),
};
}, [getAccessToken]);
return client;
}Điểm cần nhớ:
- Dùng
credentials: 'include'nếu refresh token nằm trong HttpOnly cookie. - Không bắn refresh song song khi nhiều request cùng trả
401. - Nếu refresh thất bại, xóa auth state và điều hướng về login.
8. Triển khai với axios interceptor (React)
Với axios, interceptor giúp gom logic auth vào một chỗ.
AXIOS INTERCEPTOR FLOW
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
type TokensStore = {
getAccessToken: () => string | null;
setAccessToken: (token: string) => void;
clear: () => void;
};
type RetryConfig = InternalAxiosRequestConfig & { _retry?: boolean };
export function createAxiosClient(store: TokensStore) {
const api = axios.create({
baseURL: "/api",
withCredentials: true,
});
let refreshPromise: Promise<string> | null = null;
const runRefreshSingleFlight = async () => {
if (!refreshPromise) {
refreshPromise = api
.post("/auth/refresh")
.then((res) => {
const nextToken = res.data?.accessToken as string;
store.setAccessToken(nextToken);
return nextToken;
})
.finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
};
api.interceptors.request.use((config) => {
const token = store.getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const original = error.config as RetryConfig | undefined;
if (!original || error.response?.status !== 401 || original._retry) {
return Promise.reject(error);
}
original._retry = true;
try {
const nextToken = await runRefreshSingleFlight();
original.headers = original.headers || {};
original.headers.Authorization = `Bearer ${nextToken}`;
return api(original);
} catch (refreshError) {
store.clear();
return Promise.reject(refreshError);
}
}
);
return api;
}Lưu ý khi dùng interceptor:
- Gắn cờ
_retryđể tránh vòng lặp vô hạn. - Tách
refreshPromisedùng chung cho toàn app. - Khi refresh fail, clear state dứt khoát thay vì retry vô hạn.
9. Xử lý thất bại refresh và UX
Đừng để app rơi vào vòng lặp refresh vô hạn.
Gợi ý:
- Lỗi mạng tạm thời: retry ngắn có backoff.
- Lỗi xác thực refresh: clear auth state, điều hướng về login.
- Hiển thị message rõ ràng: "Phiên đăng nhập đã hết hạn, vui lòng đăng nhập lại.".
10. Tổng kết triển khai thực chiến
- Access token TTL ngắn.
- Chủ động refresh trước hạn khoảng 10-30 giây (kèm jitter/clock-skew buffer).
- Refresh token TTL dài hơn nhưng có giới hạn rõ ràng.
- Bật refresh token rotation.
- Refresh token nằm trong HttpOnly cookie.
- Client có single-flight refresh lock.
- Backend có revoke + giám sát bất thường.
11. Kết luận
Refresh token không chỉ là kỹ thuật "tự động đăng nhập lại". Nó là quyết định kiến trúc cho bảo mật phiên của toàn bộ hệ thống.
Làm đúng cơ chế này giúp cân bằng được cả hai mục tiêu:
- UX tốt (không logout vô lý),
- Security tốt hơn (access ngắn hạn, phiên có thể revoke).
Nếu hệ thống của bạn vẫn dùng access token sống dài mà chưa có rotation, đây thường là một trong những nâng cấp auth có tác động lớn nhất.