- 1. Tặt vấn đề: Thách thức Bảo mật khi Xác thực trong Kiến trúc NextJS và Laravel Decoupled
- 2. Mô hình Kiến trúc Token Rotation & BFF Layer
- 3. Triển khai Backend Laravel: Sanctum Token Rotation API
- 4. Triển khai Route Handler làm BFF Proxy trên NextJS App Router
- 5. Hóa giải Bottleneck: Xử lý Race Condition khi Gọi Refresh Token Đồng Thời
- 6. Tổng kết và Best Practices
1. Tặt vấn đề: Thách thức Bảo mật khi Xác thực trong Kiến trúc NextJS và Laravel Decoupled
Trong các hệ thống ứng dụng web hiện đại, việc kết hợp giữa NextJS App Router (đảm nhận vai trò Frontend Rendering / BFF Layer) và Laravel (đảm nhận vai trò Headless API Gateway) ngày càng trở nên phổ biến. Tuy nhiên, thách thức lớn nhất của mô hình kiến trúc phân tán này nằm ở cơ chế quản lý phiên đăng nhập (Authentication) và ủy quyền (Authorization).
Nếu lưu trữ Access Token trực tiếp trong localStorage hoặc sessionStorage của trình duyệt, ứng dụng sẽ đối mặt với nguy cơ lỗ hổng Cross-Site Scripting (XSS). Kẻ tấn công chỉ cần thực thi một đoạn mã JavaScript độc hại là có thể đánh cắp token và giả mạo danh tính người dùng. Rủi ro này đặc biệt nghiêm trọng khi Access Token có thời hạn tồn tại lâu dài (Long-lived token).
Ngược lại, nếu lưu trữ token hoàn toàn trong HttpOnly Cookie do Laravel cấp phát, trình duyệt sẽ gặp khó khăn khi làm việc với cơ chế Server-Side Rendering (SSR) hoặc Server Actions trên NextJS, đồng thời phát sinh rủi ro lỗ hổng Cross-Site Request Forgery (CSRF) nếu cấu hình CORS và SameSite không chính xác.
Giải pháp chuẩn công nghiệp nhằm giải quyết triệt để bài toán này là kết hợp cơ chế Refresh Token Rotation cùng mô hình Backend-For-Frontend (BFF) Bridge. Bài viết này sẽ phân tích chi tiết kỹ thuật triển khai mô hình trên.
2. Mô hình Kiến trúc Token Rotation & BFF Layer
Để đảm bảo nguyên tắc bảo mật chuyên sâu (Defense in Depth), chúng ta phân chia trách nhiệm quản lý token như sau:
- Short-lived Access Token (15 phút): Được lưu trữ tại bộ nhớ tạm (In-Memory) của ứng dụng NextJS (hoặc truyền qua Authorization Header). Dùng để truy cập các tài nguyên API được bảo vệ.
- Long-lived Refresh Token (7 ngày): Được lưu trữ dưới dạng
HttpOnly Cookiedo lớp BFF Proxy của NextJS quản lý. Token này chỉ được phép sử dụng duy nhất một lần (Single-use) thông qua cơ chế Refresh Token Rotation. - Laravel API Server: Chỉ giao tiếp và xác thực chữ ký token (JWT hoặc Sanctum Token), lưu trữ trạng thái thu hồi (Token Revocation List) trong Redis nhằm vô hiệu hóa tức thì các token đã bị lộ.
Sơ đồ luồng xử lý Refresh Token Rotation
- NextJS Client gửi request gọi API. Nếu nhận về mã lỗi
401 Unauthorized, client biết rằngAccess Tokenđã hết hạn. - Client tự động gọi một HTTP POST request tới Route Handler của NextJS (ví dụ:
/api/auth/refresh). - NextJS Route Handler (BFF) đọc
HttpOnly CookiechứaRefresh Tokencũ và chuyển tiếp đến Laravel API Server. - Laravel kiểm tra tính hợp lệ của
Refresh Token. Nếu hợp lệ, Laravel lập tức hủy (revoke) token cũ, sinh ra cặpAccess Token+Refresh Tokenmới và trả về cho NextJS. - NextJS cập nhật
HttpOnly Cookiemới và trảAccess Tokenmới về cho Client tiếp tục gọi API.
3. Triển khai Backend Laravel: Sanctum Token Rotation API
Trên Laravel, chúng ta cần tùy biến cơ chế cấp phát token của Laravel Sanctum hoặc Passport để hỗ trợ cơ chế thu hồi token ngay khi được sử dụng.
Tạo một controller xử lý quá trình làm mới token trong Laravel:
<?php
namespace App\\Http\\Controllers\\Api;
use App\\Http\\Controllers\\Controller;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\DB;
use Laravel\\Sanctum\\PersonalAccessToken;
class AuthController extends Controller
{
public function refreshToken(Request $request)
{
$rawRefreshToken = $request->bearerToken() ?? $request->cookie('refresh_token');
if (!$rawRefreshToken) {
return response()->json(['message' => 'Refresh token is missing'], 401);
}
$tokenModel = PersonalAccessToken::findToken($rawRefreshToken);
// Kiểm tra tính hợp lệ và ability của refresh token
if (!$tokenModel || !$tokenModel->can('issue-access-token')) {
return response()->json(['message' => 'Invalid or expired refresh token'], 401);
}
// Kiểm tra token đã hết hạn chưa
if ($tokenModel->expires_at && $tokenModel->expires_at->isPast()) {
$tokenModel->delete();
return response()->json(['message' => 'Refresh token has expired'], 401);
}
$user = $tokenModel->tokenable;
return DB::transaction(function () use ($tokenModel, $user) {
// Xóa Refresh Token cũ ngay lập tức (Token Rotation)
$tokenModel->delete();
// Tạo Access Token mới (Thời gian sống: 15 phút)
$newAccessToken = $user->createToken('access_token', ['*'], now()->addMinutes(15))->plainTextToken;
// Tạo Refresh Token mới (Thời gian sống: 7 ngày)
$newRefreshToken = $user->createToken('refresh_token', ['issue-access-token'], now()->addDays(7))->plainTextToken;
return response()->json([
'access_token' => $newAccessToken,
'token_type' => 'Bearer',
'expires_in' => 900,
])->withCookie(
cookie('refresh_token', $newRefreshToken, 60 * 24 * 7, '/', null, true, true, false, 'Strict')
);
});
}
}
4. Triển khai Route Handler làm BFF Proxy trên NextJS App Router
Để ẩn hoàn toàn Refresh Token khỏi mã JavaScript phía Client, chúng ta xây dựng Route Handler trong NextJS đóng vai trò Middleware bảo mật.
Tạo file app/api/auth/refresh/route.ts trong thư mục NextJS App Router:
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function POST(request: NextRequest) {
const refreshToken = request.cookies.get('refresh_token')->value;
if (!refreshToken) {
return NextResponse.json(
{ error: 'Refresh token not found' },
{ status: 401 }
);
}
try {
const backendResponse = await fetch(`${process.env.LARAVEL_API_URL}/api/v1/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${refreshToken}`,
'Accept': 'application/json',
},
});
if (!backendResponse.ok) {
// Nếu Refresh Token bị từ chối, xóa cookie phía NextJS
const response = NextResponse.json(
{ error: 'Session expired. Please log in again.' },
{ status: 401 }
);
response.cookies.delete('refresh_token');
return response;
}
const data = await backendResponse.json();
const newSetCookieHeader = backendResponse.headers.get('set-cookie');
const response = NextResponse.json({
accessToken: data.access_token,
expiresIn: data.expires_in,
});
// Cập nhật Cookie HttpOnly mới từ Laravel phản hồi về
if (newSetCookieHeader) {
response.headers.set('Set-Cookie', newSetCookieHeader);
}
return response;
} catch (error) {
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}
5. Hóa giải Bottleneck: Xử lý Race Condition khi Gọi Refresh Token Đồng Thời
Một sự cố cực kỳ phổ biến trong thực tế: Khi trang web load, hàng loạt API request cùng đồng thời bắn ra (như fetch UserInfo, fetch Notifications, fetch Dashboard Data). Nếu Access Token hết hạn, tất cả các request này đều nhận về 401 Unauthorized và đồng thời gửi yêu cầu refresh token.
Do chúng ta áp dụng cơ chế Refresh Token Rotation (Refresh Token bị hủy ngay lập tức sau lần gọi đầu tiên), request thứ hai gửi đến Laravel sẽ bị coi là dùng lại token cũ và bị hủy toàn bộ phiên làm việc (Reuse Detection Penalty).
Để xử lý vấn đề này, tại Client Side ta phải thiết kế một cơ chế Request Queueing với Mutex Lock.
Triển khai Custom Fetch Interceptor với Queueing
let isRefreshing = false;
let failedQueue = [];
const processQueue = (error, token = null) => {
failedQueue.forEach((promise) => {
if (error) {
promise.reject(error);
} else {
promise.resolve(token);
}
});
failedQueue = [];
};
let inMemoryAccessToken = null;
export const setAccessToken = (token) => {
inMemoryAccessToken = token;
};
export async function customFetch(url, options = {}) {
options.headers = {
...options.headers,
'Authorization': `Bearer ${inMemoryAccessToken}`,
'Content-Type': 'application/json',
};
let response = await fetch(url, options);
if (response.status === 401) {
if (!isRefreshing) {
isRefreshing = true;
try {
const refreshResponse = await fetch('/api/auth/refresh', { method: 'POST' });
if (refreshResponse.ok) {
const data = await refreshResponse.json();
setAccessToken(data.accessToken);
processQueue(null, data.accessToken);
isRefreshing = false;
// Thực hiện lại request ban đầu với token mới
options.headers['Authorization'] = `Bearer ${data.accessToken}`;
return await fetch(url, options);
} else {
processQueue(new Error('Session Expired'), null);
isRefreshing = false;
window.location.href = '/login';
}
} catch (err) {
processQueue(err, null);
isRefreshing = false;
window.location.href = '/login';
}
} else {
// Đưa các request tiếp theo vào hàng đợi chờ refresh thành công
return new Promise((resolve, reject) => {
failedQueue.push({
resolve: (newToken) => {
options.headers['Authorization'] = `Bearer ${newToken}`;
resolve(fetch(url, options));
},
reject: (err) => {
reject(err);
},
});
});
}
}
return response;
}
6. Tổng kết và Best Practices
Mô hình kết hợp giữa Refresh Token Rotation và lớp NextJS BFF Proxy cung cấp giải pháp bảo mật toàn diện cho hệ thống Web Fullstack. Giải pháp này triệt tiêu nguy cơ lộ token qua XSS nhờ sử dụng HttpOnly Cookie, đồng thời ngăn chặn tuyệt đối các cuộc tấn công Replay Attack nhờ tính chất Single-use của Refresh Token.
Để thành thạo tư duy thiết kế kiến trúc chuẩn doanh nghiệp và tối ưu hóa hiệu năng kết nối giữa hai framework hàng đầu hiện nay, bạn có thể tham gia lộ trình đào tạo chuyên sâu. Tham khảo khóa học "Xây dựng ứng dụng kết hợp Laravel - ReactJS - NextJS" tại đây.





Bình luận 0
Chia sẻ ý kiến hoặc đặt câu hỏi cùng cộng đồng