Đi đến nội dung
·31 phút đọc

Xác Thực Hiện Đại năm 2026: JWT, Sessions, OAuth và Passkeys

Toàn cảnh xác thực: khi nào dùng sessions vs JWT, OAuth 2.0/OIDC flows, refresh token rotation, passkeys (WebAuthn), và pattern auth Next.js tôi thực sự sử dụng.

Chia sẻ:X / TwitterLinkedIn

Xác thực là lĩnh vực duy nhất trong phát triển web mà "nó hoạt động" không bao giờ đủ tốt. Một bug trong date picker gây khó chịu. Một bug trong hệ thống xác thực là vi phạm dữ liệu.

Tôi đã triển khai xác thực từ đầu, di chuyển giữa các provider, debug các sự cố đánh cắp token, và đối phó với hậu quả của các quyết định "chúng ta sẽ sửa bảo mật sau". Bài viết này là hướng dẫn toàn diện mà tôi ước mình đã có khi bắt đầu. Không chỉ lý thuyết — mà là các đánh đổi thực tế, các lỗ hổng thực, và các pattern vững chắc dưới áp lực production.

Chúng ta sẽ bao phủ toàn bộ cảnh quan: sessions, JWT, OAuth 2.0, passkeys, MFA và authorization. Đến cuối bài, bạn sẽ hiểu không chỉ cách mỗi cơ chế hoạt động, mà còn khi nào sử dụng nó và tại sao các phương án thay thế tồn tại.

Sessions vs JWT: Những Đánh Đổi Thực Sự#

Đây là quyết định đầu tiên bạn sẽ đối mặt, và internet đầy lời khuyên tồi về nó. Hãy để tôi trình bày những gì thực sự quan trọng.

Xác Thực Dựa Trên Session#

Session là cách tiếp cận ban đầu. Server tạo bản ghi session, lưu trữ ở đâu đó (database, Redis, bộ nhớ), và đưa cho client một session ID không rõ nghĩa trong cookie.

typescript
// Tạo session đơn giản
import { randomBytes } from "crypto";
import { cookies } from "next/headers";
 
interface Session {
  userId: string;
  createdAt: Date;
  expiresAt: Date;
  ipAddress: string;
  userAgent: string;
}
 
async function createSession(userId: string, request: Request): Promise<string> {
  const sessionId = randomBytes(32).toString("hex");
 
  const session: Session = {
    userId,
    createdAt: new Date(),
    expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 giờ
    ipAddress: request.headers.get("x-forwarded-for") ?? "unknown",
    userAgent: request.headers.get("user-agent") ?? "unknown",
  };
 
  // Lưu trong database hoặc Redis
  await redis.set(`session:${sessionId}`, JSON.stringify(session), "EX", 86400);
 
  const cookieStore = await cookies();
  cookieStore.set("session_id", sessionId, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    maxAge: 86400,
    path: "/",
  });
 
  return sessionId;
}

Các ưu điểm là thực:

  • Thu hồi tức thì. Xóa bản ghi session và người dùng bị đăng xuất. Không cần chờ hết hạn. Điều này quan trọng khi bạn phát hiện hoạt động đáng ngờ.
  • Hiển thị session. Bạn có thể hiển thị cho người dùng các session đang hoạt động ("đã đăng nhập trên Chrome, Windows 11, Istanbul") và cho phép họ thu hồi từng cái.
  • Kích thước cookie nhỏ. Session ID thường là 64 ký tự. Cookie không bao giờ tăng lên.
  • Kiểm soát phía server. Bạn có thể cập nhật dữ liệu session (thăng cấp user lên admin, thay đổi quyền) và nó có hiệu lực ngay request tiếp theo.

Các nhược điểm cũng thực:

  • Truy vấn database mỗi request. Mỗi request đã xác thực cần lookup session. Với Redis thì dưới mili giây, nhưng vẫn là một dependency.
  • Mở rộng ngang yêu cầu storage chia sẻ. Nếu bạn có nhiều server, tất cả cần truy cập cùng session store. Sticky session là giải pháp tạm bợ mong manh.
  • CSRF là mối lo. Vì cookie được gửi tự động, bạn cần bảo vệ CSRF. Cookie SameSite giải quyết phần lớn điều này, nhưng bạn cần hiểu tại sao.

Xác Thực Dựa Trên JWT#

JWT đảo ngược mô hình. Thay vì lưu trạng thái session trên server, bạn mã hóa nó vào một token có chữ ký mà client giữ.

typescript
import { SignJWT, jwtVerify } from "jose";
 
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
 
async function createAccessToken(userId: string, role: string): Promise<string> {
  return new SignJWT({ sub: userId, role })
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime("15m")
    .setIssuer("https://akousa.net")
    .setAudience("https://akousa.net")
    .sign(secret);
}
 
async function verifyAccessToken(token: string) {
  try {
    const { payload } = await jwtVerify(token, secret, {
      issuer: "https://akousa.net",
      audience: "https://akousa.net",
    });
    return payload;
  } catch {
    return null;
  }
}

Các ưu điểm:

  • Không cần storage phía server. Token tự chứa. Bạn xác minh chữ ký và đọc claims. Không truy vấn database.
  • Hoạt động xuyên service. Trong kiến trúc microservices, bất kỳ service nào có public key đều có thể xác minh token. Không cần session store chia sẻ.
  • Mở rộng stateless. Thêm server mà không lo session affinity.

Các nhược điểm — và đây là những thứ mọi người lướt qua:

  • Bạn không thể thu hồi JWT. Một khi được phát hành, nó hợp lệ cho đến khi hết hạn. Nếu tài khoản người dùng bị xâm phạm, bạn không thể buộc đăng xuất. Bạn có thể xây blocklist, nhưng khi đó bạn đã đưa trạng thái phía server trở lại và mất ưu điểm chính.
  • Kích thước token. JWT với vài claims thường 800+ byte. Thêm roles, permissions và metadata và bạn đang gửi kilobyte mỗi request.
  • Payload có thể đọc được. Payload được mã hóa Base64, không được mã hóa. Bất kỳ ai cũng có thể giải mã nó. Không bao giờ đặt dữ liệu nhạy cảm trong JWT.
  • Vấn đề lệch đồng hồ. Nếu server của bạn có đồng hồ khác nhau (điều này xảy ra), kiểm tra hết hạn trở nên không đáng tin cậy.

Khi Nào Sử Dụng Mỗi Loại#

Quy tắc chung của tôi:

Sử dụng session khi: Bạn có ứng dụng monolithic, bạn cần thu hồi tức thì, bạn đang xây sản phẩm hướng người tiêu dùng nơi bảo mật tài khoản quan trọng, hoặc yêu cầu auth của bạn có thể thay đổi thường xuyên.

Sử dụng JWT khi: Bạn có kiến trúc microservices nơi các service cần xác minh danh tính độc lập, bạn đang xây giao tiếp API-to-API, hoặc bạn đang triển khai hệ thống xác thực bên thứ ba.

Trong thực tế: Hầu hết ứng dụng nên sử dụng session. Lập luận "JWT có khả năng mở rộng hơn" chỉ áp dụng nếu bạn thực sự có vấn đề mở rộng mà session storage không thể giải quyết — và Redis xử lý hàng triệu session lookup mỗi giây. Tôi đã thấy quá nhiều dự án chọn JWT vì chúng nghe hiện đại hơn, rồi xây blocklist và hệ thống refresh token phức tạp hơn session.

JWT Deep Dive#

Ngay cả khi bạn chọn auth dựa trên session, bạn sẽ gặp JWT thông qua OAuth, OIDC và tích hợp bên thứ ba. Hiểu nội bộ là không thể thương lượng.

Cấu Trúc JWT#

JWT có ba phần ngăn cách bởi dấu chấm: header.payload.signature

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcwOTMxMjAwMCwiZXhwIjoxNzA5MzEyOTAwfQ.
kQ8s7nR2xC...

Header — khai báo thuật toán và loại token:

json
{
  "alg": "RS256",
  "typ": "JWT"
}

Payload — chứa các claims. Claims chuẩn có tên ngắn:

json
{
  "sub": "user_123",       // Subject (ai là chủ thể)
  "iss": "https://auth.example.com",  // Issuer (ai tạo ra)
  "aud": "https://api.example.com",   // Audience (ai nên chấp nhận)
  "iat": 1709312000,       // Issued At (Unix timestamp)
  "exp": 1709312900,       // Expiration (Unix timestamp)
  "role": "admin"          // Claim tùy chỉnh
}

Signature — chứng minh token chưa bị can thiệp. Được tạo bằng cách ký header và payload đã mã hóa bằng secret key.

RS256 vs HS256: Điều Này Thực Sự Quan Trọng#

HS256 (HMAC-SHA256) — đối xứng. Cùng secret ký và xác minh. Đơn giản, nhưng mọi service cần xác minh token đều phải có secret. Nếu bất kỳ cái nào bị xâm phạm, kẻ tấn công có thể giả mạo token.

RS256 (RSA-SHA256) — bất đối xứng. Private key ký, public key xác minh. Chỉ auth server cần private key. Bất kỳ service nào đều có thể xác minh bằng public key. Nếu service xác minh bị xâm phạm, kẻ tấn công có thể đọc token nhưng không giả mạo được.

typescript
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from "jose";
 
// RS256 — sử dụng khi nhiều service xác minh token
const privateKeyPem = process.env.JWT_PRIVATE_KEY!;
const publicKeyPem = process.env.JWT_PUBLIC_KEY!;
 
async function signWithRS256(payload: Record<string, unknown>) {
  const privateKey = await importPKCS8(privateKeyPem, "RS256");
 
  return new SignJWT(payload)
    .setProtectedHeader({ alg: "RS256", typ: "JWT" })
    .setIssuedAt()
    .setExpirationTime("15m")
    .sign(privateKey);
}
 
async function verifyWithRS256(token: string) {
  const publicKey = await importSPKI(publicKeyPem, "RS256");
 
  const { payload } = await jwtVerify(token, publicKey, {
    algorithms: ["RS256"], // QUAN TRỌNG: luôn giới hạn thuật toán
  });
 
  return payload;
}

Quy tắc: Sử dụng RS256 bất cứ khi nào token đi qua ranh giới service. Chỉ sử dụng HS256 khi cùng service vừa ký vừa xác minh.

Tấn Công alg: none#

Đây là lỗ hổng JWT nổi tiếng nhất, và nó đáng xấu hổ đơn giản. Một số thư viện JWT trước đây:

  1. Đọc trường alg từ header
  2. Sử dụng bất kỳ thuật toán nào nó chỉ định
  3. Nếu alg: "none", bỏ qua xác minh chữ ký hoàn toàn

Kẻ tấn công có thể lấy JWT hợp lệ, thay đổi payload (ví dụ, đặt "role": "admin"), đặt alg thành "none", xóa chữ ký, và gửi đi. Server sẽ chấp nhận.

typescript
// DỄ BỊ TẤN CÔNG — không bao giờ làm điều này
function verifyJwt(token: string) {
  const [headerB64, payloadB64, signature] = token.split(".");
  const header = JSON.parse(atob(headerB64));
 
  if (header.alg === "none") {
    // "Không cần chữ ký" — THẢM HỌA
    return JSON.parse(atob(payloadB64));
  }
 
  // ... xác minh chữ ký
}

Cách sửa rất đơn giản: luôn chỉ định thuật toán mong đợi một cách rõ ràng. Không bao giờ để token nói cho bạn cách xác minh nó.

typescript
// AN TOÀN — thuật toán được hardcode, không đọc từ token
const { payload } = await jwtVerify(token, key, {
  algorithms: ["RS256"], // Chỉ chấp nhận RS256 — bỏ qua header
});

Các thư viện hiện đại như jose xử lý điều này đúng cách mặc định, nhưng bạn vẫn nên truyền tùy chọn algorithms rõ ràng như phòng thủ chiều sâu.

Tấn Công Nhầm Lẫn Thuật Toán#

Liên quan đến phần trên: nếu server được cấu hình chấp nhận RS256, kẻ tấn công có thể:

  1. Lấy public key của server (nó là public, dĩ nhiên)
  2. Tạo token với alg: "HS256"
  3. Ký nó sử dụng public key làm HMAC secret

Nếu server đọc header alg và chuyển sang xác minh HS256, public key (mà mọi người đều biết) trở thành shared secret. Chữ ký hợp lệ. Kẻ tấn công đã giả mạo token.

Lại lần nữa, cách sửa giống nhau: không bao giờ tin thuật toán từ token header. Luôn hardcode nó.

Xoay Vòng Refresh Token#

Nếu bạn sử dụng JWT, bạn cần chiến lược refresh token. Gửi access token có thời gian sống dài là tự chuốc rắc rối — nếu nó bị đánh cắp, kẻ tấn công có quyền truy cập trong toàn bộ thời gian sống.

Pattern:

  • Access token: ngắn hạn (15 phút). Sử dụng cho API request.
  • Refresh token: dài hạn (30 ngày). Chỉ sử dụng để lấy access token mới.
typescript
import { randomBytes } from "crypto";
 
interface RefreshTokenRecord {
  tokenHash: string;
  userId: string;
  familyId: string;  // Nhóm các token liên quan lại với nhau
  used: boolean;
  expiresAt: Date;
  createdAt: Date;
}
 
async function issueTokenPair(userId: string) {
  const familyId = randomBytes(16).toString("hex");
 
  const accessToken = await createAccessToken(userId);
  const refreshToken = randomBytes(64).toString("hex");
  const refreshTokenHash = await hashToken(refreshToken);
 
  // Lưu bản ghi refresh token
  await db.refreshToken.create({
    data: {
      tokenHash: refreshTokenHash,
      userId,
      familyId,
      used: false,
      expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
      createdAt: new Date(),
    },
  });
 
  return { accessToken, refreshToken };
}

Xoay Vòng Mỗi Lần Sử Dụng#

Mỗi khi client sử dụng refresh token để lấy access token mới, bạn phát hành refresh token mới và vô hiệu hóa cái cũ:

typescript
async function rotateTokens(incomingRefreshToken: string) {
  const tokenHash = await hashToken(incomingRefreshToken);
  const record = await db.refreshToken.findUnique({
    where: { tokenHash },
  });
 
  if (!record) {
    // Token không tồn tại — có thể bị đánh cắp
    return null;
  }
 
  if (record.expiresAt < new Date()) {
    // Token hết hạn
    await db.refreshToken.delete({ where: { tokenHash } });
    return null;
  }
 
  if (record.used) {
    // TOKEN NÀY ĐÃ ĐƯỢC SỬ DỤNG RỒI.
    // Ai đó đang replay nó — hoặc người dùng hợp pháp
    // hoặc kẻ tấn công. Dù sao, tiêu diệt toàn bộ family.
    await db.refreshToken.deleteMany({
      where: { familyId: record.familyId },
    });
 
    console.error(
      `Refresh token reuse detected for user ${record.userId}, family ${record.familyId}. All tokens in family invalidated.`
    );
 
    return null;
  }
 
  // Đánh dấu token hiện tại đã sử dụng (không xóa — cần cho phát hiện tái sử dụng)
  await db.refreshToken.update({
    where: { tokenHash },
    data: { used: true },
  });
 
  // Phát hành cặp mới với cùng family ID
  const newRefreshToken = randomBytes(64).toString("hex");
  const newRefreshTokenHash = await hashToken(newRefreshToken);
 
  await db.refreshToken.create({
    data: {
      tokenHash: newRefreshTokenHash,
      userId: record.userId,
      familyId: record.familyId,  // Cùng family
      used: false,
      expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
      createdAt: new Date(),
    },
  });
 
  const newAccessToken = await createAccessToken(record.userId);
 
  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}

Tại Sao Vô Hiệu Hóa Family Quan Trọng#

Xem xét kịch bản này:

  1. Người dùng đăng nhập, nhận refresh token A
  2. Kẻ tấn công đánh cắp refresh token A
  3. Kẻ tấn công sử dụng A để lấy cặp mới (access token + refresh token B)
  4. Người dùng thử sử dụng A (mà họ vẫn còn) để refresh

Không có phát hiện tái sử dụng, người dùng chỉ nhận lỗi. Kẻ tấn công tiếp tục với token B. Người dùng đăng nhập lại, không biết tài khoản bị xâm phạm.

Với phát hiện tái sử dụng và vô hiệu hóa family: khi người dùng thử sử dụng token A đã dùng, hệ thống phát hiện tái sử dụng, vô hiệu hóa mọi token trong family (bao gồm B), và buộc cả người dùng và kẻ tấn công xác thực lại. Người dùng nhận thông báo "vui lòng đăng nhập lại" và có thể nhận ra điều gì đó không đúng.

Đây là cách tiếp cận được sử dụng bởi Auth0, Okta và Auth.js. Nó không hoàn hảo — nếu kẻ tấn công sử dụng token trước người dùng hợp pháp, người dùng hợp pháp trở thành người kích hoạt cảnh báo tái sử dụng. Nhưng đây là tốt nhất chúng ta có thể làm với bearer token.

OAuth 2.0 & OIDC#

OAuth 2.0 và OpenID Connect là các giao thức đằng sau "Đăng nhập bằng Google/GitHub/Apple." Hiểu chúng là thiết yếu ngay cả khi bạn sử dụng thư viện, vì khi mọi thứ hỏng — và sẽ hỏng — bạn cần biết chuyện gì đang xảy ra ở cấp giao thức.

Phân Biệt Quan Trọng#

OAuth 2.0 là giao thức authorization. Nó trả lời: "Ứng dụng này có thể truy cập dữ liệu của người dùng này không?" Kết quả là access token cấp các quyền cụ thể (scope).

OpenID Connect (OIDC) là lớp authentication xây trên OAuth 2.0. Nó trả lời: "Người dùng này là ai?" Kết quả là ID token (một JWT) chứa thông tin danh tính người dùng.

Khi bạn "Đăng nhập bằng Google," bạn đang sử dụng OIDC. Google nói cho ứng dụng biết người dùng là ai (authentication). Bạn cũng có thể yêu cầu OAuth scope để truy cập lịch hoặc drive của họ (authorization).

Authorization Code Flow với PKCE#

Đây là flow bạn nên sử dụng cho ứng dụng web. PKCE (Proof Key for Code Exchange) ban đầu được thiết kế cho ứng dụng mobile nhưng giờ được khuyến nghị cho tất cả client, bao gồm ứng dụng phía server.

typescript
import { randomBytes, createHash } from "crypto";
 
// Bước 1: Tạo giá trị PKCE và redirect người dùng
function initiateOAuthFlow() {
  // Code verifier: chuỗi ngẫu nhiên 43-128 ký tự
  const codeVerifier = randomBytes(32)
    .toString("base64url")
    .slice(0, 43);
 
  // Code challenge: SHA256 hash của verifier, mã hóa base64url
  const codeChallenge = createHash("sha256")
    .update(codeVerifier)
    .digest("base64url");
 
  // State: giá trị ngẫu nhiên để bảo vệ CSRF
  const state = randomBytes(16).toString("hex");
 
  // Lưu cả hai trong session (phía server!) trước khi redirect
  // KHÔNG BAO GIỜ đặt code_verifier trong cookie hoặc URL parameter
  session.codeVerifier = codeVerifier;
  session.oauthState = state;
 
  const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");
  authUrl.searchParams.set("client_id", process.env.GOOGLE_CLIENT_ID!);
  authUrl.searchParams.set("redirect_uri", "https://example.com/api/auth/callback/google");
  authUrl.searchParams.set("response_type", "code");
  authUrl.searchParams.set("scope", "openid email profile");
  authUrl.searchParams.set("state", state);
  authUrl.searchParams.set("code_challenge", codeChallenge);
  authUrl.searchParams.set("code_challenge_method", "S256");
 
  return authUrl.toString();
}
typescript
// Bước 2: Xử lý callback
async function handleOAuthCallback(request: Request) {
  const url = new URL(request.url);
  const code = url.searchParams.get("code");
  const state = url.searchParams.get("state");
  const error = url.searchParams.get("error");
 
  // Kiểm tra lỗi từ provider
  if (error) {
    throw new Error(`OAuth error: ${error}`);
  }
 
  // Xác minh state khớp (bảo vệ CSRF)
  if (state !== session.oauthState) {
    throw new Error("State mismatch — possible CSRF attack");
  }
 
  // Đổi authorization code lấy token
  const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code: code!,
      redirect_uri: "https://example.com/api/auth/callback/google",
      client_id: process.env.GOOGLE_CLIENT_ID!,
      client_secret: process.env.GOOGLE_CLIENT_SECRET!,
      code_verifier: session.codeVerifier, // PKCE: chứng minh chúng ta đã bắt đầu flow này
    }),
  });
 
  const tokens = await tokenResponse.json();
  // tokens.access_token — cho API call đến Google
  // tokens.id_token — JWT với danh tính người dùng (OIDC)
  // tokens.refresh_token — để lấy access token mới
 
  // Bước 3: Xác minh ID token và trích xuất thông tin người dùng
  const idTokenPayload = await verifyGoogleIdToken(tokens.id_token);
 
  return {
    googleId: idTokenPayload.sub,
    email: idTokenPayload.email,
    name: idTokenPayload.name,
    picture: idTokenPayload.picture,
  };
}

Ba Endpoint#

Mỗi provider OAuth/OIDC expose những endpoint này:

  1. Authorization endpoint — nơi bạn redirect người dùng để đăng nhập và cấp quyền. Trả về authorization code.
  2. Token endpoint — nơi server của bạn đổi authorization code lấy access/refresh/ID token. Đây là cuộc gọi server-to-server.
  3. UserInfo endpoint — nơi bạn có thể lấy thêm dữ liệu hồ sơ người dùng bằng access token. Với OIDC, nhiều thứ đã có trong ID token.

Tham Số State#

Tham số state ngăn tấn công CSRF trên OAuth callback. Không có nó:

  1. Kẻ tấn công bắt đầu flow OAuth trên máy riêng, lấy authorization code
  2. Kẻ tấn công tạo URL: https://yourapp.com/callback?code=ATTACKER_CODE
  3. Kẻ tấn công lừa nạn nhân nhấp vào (email link, ảnh ẩn)
  4. Ứng dụng đổi code của kẻ tấn công và liên kết tài khoản Google của kẻ tấn công với session của nạn nhân

Với state: ứng dụng tạo giá trị ngẫu nhiên, lưu trong session, và đưa vào authorization URL. Khi callback đến, bạn xác minh state khớp. Kẻ tấn công không thể giả mạo vì họ không có quyền truy cập session của nạn nhân.

Auth.js (NextAuth) với Next.js App Router#

Auth.js là thứ tôi chọn đầu tiên trong hầu hết dự án Next.js. Nó xử lý OAuth dance, quản lý session, persistence database, và bảo vệ CSRF. Đây là setup sẵn sàng production.

Cấu Hình Cơ Bản#

typescript
// src/lib/auth.ts
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import { verifyPassword } from "@/lib/password";
 
export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
 
  // Sử dụng database session (không phải JWT) cho bảo mật tốt hơn
  session: {
    strategy: "database",
    maxAge: 30 * 24 * 60 * 60, // 30 ngày
    updateAge: 24 * 60 * 60,   // Gia hạn session mỗi 24 giờ
  },
 
  providers: [
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
      // Yêu cầu scope cụ thể
      authorization: {
        params: {
          scope: "openid email profile",
          prompt: "consent",
          access_type: "offline", // Lấy refresh token
        },
      },
    }),
 
    GitHub({
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    }),
 
    // Đăng nhập email/mật khẩu (sử dụng cẩn thận)
    Credentials({
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" },
      },
      authorize: async (credentials) => {
        if (!credentials?.email || !credentials?.password) {
          return null;
        }
 
        const user = await prisma.user.findUnique({
          where: { email: credentials.email as string },
        });
 
        if (!user || !user.passwordHash) {
          return null;
        }
 
        const isValid = await verifyPassword(
          credentials.password as string,
          user.passwordHash
        );
 
        if (!isValid) {
          return null;
        }
 
        return {
          id: user.id,
          email: user.email,
          name: user.name,
          image: user.image,
        };
      },
    }),
  ],
 
  callbacks: {
    // Kiểm soát ai có thể đăng nhập
    async signIn({ user, account }) {
      // Chặn đăng nhập cho user bị cấm
      if (user.id) {
        const dbUser = await prisma.user.findUnique({
          where: { id: user.id },
          select: { banned: true },
        });
        if (dbUser?.banned) return false;
      }
      return true;
    },
 
    // Thêm trường tùy chỉnh vào session
    async session({ session, user }) {
      if (session.user) {
        session.user.id = user.id;
        // Lấy role từ database
        const dbUser = await prisma.user.findUnique({
          where: { id: user.id },
          select: { role: true },
        });
        session.user.role = dbUser?.role ?? "user";
      }
      return session;
    },
  },
 
  pages: {
    signIn: "/login",
    error: "/auth/error",
    verifyRequest: "/auth/verify",
  },
});

Route Handler#

typescript
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
 
export const { GET, POST } = handlers;

Middleware Protection#

typescript
// src/middleware.ts
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
 
export default auth((req) => {
  const isLoggedIn = !!req.auth;
  const isAuthPage = req.nextUrl.pathname.startsWith("/login")
    || req.nextUrl.pathname.startsWith("/register");
  const isProtectedRoute = req.nextUrl.pathname.startsWith("/dashboard")
    || req.nextUrl.pathname.startsWith("/settings")
    || req.nextUrl.pathname.startsWith("/admin");
  const isAdminRoute = req.nextUrl.pathname.startsWith("/admin");
 
  // Redirect user đã đăng nhập ra khỏi trang auth
  if (isLoggedIn && isAuthPage) {
    return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
  }
 
  // Redirect user chưa xác thực đến trang đăng nhập
  if (!isLoggedIn && isProtectedRoute) {
    const callbackUrl = encodeURIComponent(req.nextUrl.pathname);
    return NextResponse.redirect(
      new URL(`/login?callbackUrl=${callbackUrl}`, req.nextUrl)
    );
  }
 
  // Kiểm tra role admin
  if (isAdminRoute && req.auth?.user?.role !== "admin") {
    return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
  }
 
  return NextResponse.next();
});
 
export const config = {
  matcher: [
    "/dashboard/:path*",
    "/settings/:path*",
    "/admin/:path*",
    "/login",
    "/register",
  ],
};

Sử Dụng Session trong Server Component#

typescript
// src/app/dashboard/page.tsx
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
 
export default async function DashboardPage() {
  const session = await auth();
 
  if (!session?.user) {
    redirect("/login");
  }
 
  return (
    <div>
      <h1>Welcome, {session.user.name}</h1>
      <p>Role: {session.user.role}</p>
    </div>
  );
}

Sử Dụng Session trong Client Component#

typescript
"use client";
 
import { useSession } from "next-auth/react";
 
export function UserMenu() {
  const { data: session, status } = useSession();
 
  if (status === "loading") {
    return <div>Loading...</div>;
  }
 
  if (status === "unauthenticated") {
    return <a href="/login">Sign In</a>;
  }
 
  return (
    <div>
      <img
        src={session?.user?.image ?? "/default-avatar.png"}
        alt={session?.user?.name ?? "User"}
      />
      <span>{session?.user?.name}</span>
    </div>
  );
}

Passkeys (WebAuthn)#

Passkey là cải tiến xác thực quan trọng nhất trong nhiều năm. Chúng chống phishing, chống replay, và loại bỏ toàn bộ danh mục lỗ hổng liên quan đến mật khẩu. Nếu bạn bắt đầu dự án mới năm 2026, bạn nên hỗ trợ passkey.

Passkey Hoạt Động Như Thế Nào#

Passkey sử dụng mật mã khóa công khai, được bảo vệ bởi sinh trắc học hoặc PIN thiết bị:

  1. Đăng ký: Trình duyệt tạo cặp khóa. Private key ở trên thiết bị (trong secure enclave, được bảo vệ bằng sinh trắc học). Public key được gửi đến server.
  2. Xác thực: Server gửi challenge (byte ngẫu nhiên). Thiết bị ký challenge bằng private key (sau khi xác minh sinh trắc học). Server xác minh chữ ký bằng public key đã lưu.

Không bao giờ có shared secret đi qua mạng. Không có gì để phishing, không có gì để rò rỉ, không có gì để stuff.

Tại Sao Passkey Chống Phishing#

Khi passkey được tạo, nó được gắn với origin (ví dụ, https://example.com). Trình duyệt sẽ chỉ sử dụng passkey trên chính xác origin mà nó được tạo. Nếu kẻ tấn công tạo trang web giả tại https://exarnple.com, passkey đơn giản sẽ không được đề xuất. Điều này được thực thi bởi trình duyệt, không phải bởi sự cảnh giác của người dùng.

Điều này khác biệt cơ bản với mật khẩu, nơi người dùng thường xuyên nhập credential trên trang phishing vì trang trông đúng.

Triển Khai với SimpleWebAuthn#

SimpleWebAuthn là thư viện tôi khuyến nghị. Nó xử lý giao thức WebAuthn đúng cách và có TypeScript type tốt.

typescript
// Phía server: Đăng ký
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
} from "@simplewebauthn/server";
import type {
  GenerateRegistrationOptionsOpts,
  VerifiedRegistrationResponse,
} from "@simplewebauthn/server";
 
const rpName = "akousa.net";
const rpID = "akousa.net";
const origin = "https://akousa.net";
 
async function startRegistration(userId: string, userEmail: string) {
  // Lấy passkey hiện có của user để loại trừ
  const existingCredentials = await db.credential.findMany({
    where: { userId },
    select: { credentialId: true, transports: true },
  });
 
  const options: GenerateRegistrationOptionsOpts = {
    rpName,
    rpID,
    userID: new TextEncoder().encode(userId),
    userName: userEmail,
    attestationType: "none", // Không cần attestation cho hầu hết ứng dụng
    excludeCredentials: existingCredentials.map((cred) => ({
      id: cred.credentialId,
      transports: cred.transports,
    })),
    authenticatorSelection: {
      residentKey: "preferred",
      userVerification: "preferred",
    },
  };
 
  const registrationOptions = await generateRegistrationOptions(options);
 
  // Lưu challenge tạm thời — cần cho xác minh
  await redis.set(
    `webauthn:challenge:${userId}`,
    registrationOptions.challenge,
    "EX",
    300 // Hết hạn 5 phút
  );
 
  return registrationOptions;
}
 
async function finishRegistration(userId: string, response: unknown) {
  const expectedChallenge = await redis.get(`webauthn:challenge:${userId}`);
 
  if (!expectedChallenge) {
    throw new Error("Challenge expired or not found");
  }
 
  let verification: VerifiedRegistrationResponse;
  try {
    verification = await verifyRegistrationResponse({
      response: response as any,
      expectedChallenge,
      expectedOrigin: origin,
      expectedRPID: rpID,
    });
  } catch (error) {
    throw new Error(`Registration verification failed: ${error}`);
  }
 
  if (!verification.verified || !verification.registrationInfo) {
    throw new Error("Registration verification failed");
  }
 
  const { credential } = verification.registrationInfo;
 
  // Lưu credential vào database
  await db.credential.create({
    data: {
      userId,
      credentialId: credential.id,
      publicKey: Buffer.from(credential.publicKey),
      counter: credential.counter,
      transports: credential.transports ?? [],
    },
  });
 
  // Dọn dẹp
  await redis.del(`webauthn:challenge:${userId}`);
 
  return { verified: true };
}
typescript
// Phía server: Xác thực
import {
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from "@simplewebauthn/server";
 
async function startAuthentication(userId?: string) {
  let allowCredentials;
 
  // Nếu biết user (ví dụ, họ đã nhập email), giới hạn vào passkey của họ
  if (userId) {
    const credentials = await db.credential.findMany({
      where: { userId },
      select: { credentialId: true, transports: true },
    });
    allowCredentials = credentials.map((cred) => ({
      id: cred.credentialId,
      transports: cred.transports,
    }));
  }
 
  const options = await generateAuthenticationOptions({
    rpID,
    allowCredentials,
    userVerification: "preferred",
  });
 
  // Lưu challenge để xác minh
  const challengeKey = userId
    ? `webauthn:auth:${userId}`
    : `webauthn:auth:${options.challenge}`;
 
  await redis.set(challengeKey, options.challenge, "EX", 300);
 
  return options;
}
 
async function finishAuthentication(
  response: any,
  expectedChallenge: string,
  userId: string
) {
  const credential = await db.credential.findUnique({
    where: { credentialId: response.id },
  });
 
  if (!credential) {
    throw new Error("Credential not found");
  }
 
  const verification = await verifyAuthenticationResponse({
    response,
    expectedChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    credential: {
      id: credential.credentialId,
      publicKey: credential.publicKey,
      counter: credential.counter,
      transports: credential.transports,
    },
  });
 
  if (!verification.verified) {
    throw new Error("Authentication verification failed");
  }
 
  // QUAN TRỌNG: Cập nhật counter để ngăn tấn công replay
  await db.credential.update({
    where: { credentialId: response.id },
    data: {
      counter: verification.authenticationInfo.newCounter,
    },
  });
 
  return { verified: true, userId: credential.userId };
}
typescript
// Phía client: Đăng ký
import { startRegistration as webAuthnRegister } from "@simplewebauthn/browser";
 
async function registerPasskey() {
  // Lấy options từ server
  const optionsResponse = await fetch("/api/auth/webauthn/register", {
    method: "POST",
  });
  const options = await optionsResponse.json();
 
  try {
    // Kích hoạt UI passkey của trình duyệt (yêu cầu sinh trắc học)
    const credential = await webAuthnRegister(options);
 
    // Gửi credential đến server để xác minh
    const verifyResponse = await fetch("/api/auth/webauthn/register/verify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(credential),
    });
 
    const result = await verifyResponse.json();
    if (result.verified) {
      console.log("Passkey registered successfully!");
    }
  } catch (error) {
    if ((error as Error).name === "NotAllowedError") {
      console.log("User cancelled the passkey registration");
    }
  }
}

Attestation vs Assertion#

Hai thuật ngữ bạn sẽ gặp:

  • Attestation (đăng ký): Quá trình tạo credential mới. Authenticator "chứng nhận" danh tính và khả năng của nó. Với hầu hết ứng dụng, bạn không cần xác minh attestation — đặt attestationType: "none".
  • Assertion (xác thực): Quá trình sử dụng credential hiện có để ký challenge. Authenticator "xác nhận" rằng người dùng là ai họ tuyên bố.

Triển Khai MFA#

Ngay cả với passkey, bạn sẽ gặp kịch bản cần MFA qua TOTP — passkey làm yếu tố thứ hai cùng mật khẩu, hoặc hỗ trợ người dùng có thiết bị không hỗ trợ passkey.

TOTP (Time-Based One-Time Passwords)#

TOTP là giao thức đằng sau Google Authenticator, Authy và 1Password. Nó hoạt động bằng cách:

  1. Server tạo secret ngẫu nhiên (mã hóa base32)
  2. Người dùng quét mã QR chứa secret
  3. Cả server và ứng dụng authenticator tính cùng mã 6 chữ số từ secret và thời gian hiện tại
  4. Mã thay đổi mỗi 30 giây
typescript
import { createHmac, randomBytes } from "crypto";
 
// Tạo TOTP secret cho user
function generateTOTPSecret(): string {
  const buffer = randomBytes(20);
  return base32Encode(buffer);
}
 
function base32Encode(buffer: Buffer): string {
  const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
  let result = "";
  let bits = 0;
  let value = 0;
 
  for (const byte of buffer) {
    value = (value << 8) | byte;
    bits += 8;
    while (bits >= 5) {
      result += alphabet[(value >>> (bits - 5)) & 0x1f];
      bits -= 5;
    }
  }
 
  if (bits > 0) {
    result += alphabet[(value << (5 - bits)) & 0x1f];
  }
 
  return result;
}
 
// Tạo TOTP URI cho mã QR
function generateTOTPUri(
  secret: string,
  userEmail: string,
  issuer: string = "akousa.net"
): string {
  const encodedIssuer = encodeURIComponent(issuer);
  const encodedEmail = encodeURIComponent(userEmail);
  return `otpauth://totp/${encodedIssuer}:${encodedEmail}?secret=${secret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=6&period=30`;
}
typescript
// Xác minh mã TOTP
function verifyTOTP(secret: string, code: string, window: number = 1): boolean {
  const secretBuffer = base32Decode(secret);
  const now = Math.floor(Date.now() / 1000);
 
  // Kiểm tra bước thời gian hiện tại và liền kề (dung sai lệch đồng hồ)
  for (let i = -window; i <= window; i++) {
    const timeStep = Math.floor(now / 30) + i;
    const expectedCode = generateTOTPCode(secretBuffer, timeStep);
 
    // So sánh constant-time để ngăn tấn công timing
    if (timingSafeEqual(code, expectedCode)) {
      return true;
    }
  }
 
  return false;
}
 
function generateTOTPCode(secret: Buffer, timeStep: number): string {
  // Chuyển bước thời gian thành buffer big-endian 8 byte
  const timeBuffer = Buffer.alloc(8);
  timeBuffer.writeBigInt64BE(BigInt(timeStep));
 
  // HMAC-SHA1
  const hmac = createHmac("sha1", secret).update(timeBuffer).digest();
 
  // Dynamic truncation
  const offset = hmac[hmac.length - 1] & 0x0f;
  const code =
    ((hmac[offset] & 0x7f) << 24) |
    ((hmac[offset + 1] & 0xff) << 16) |
    ((hmac[offset + 2] & 0xff) << 8) |
    (hmac[offset + 3] & 0xff);
 
  return (code % 1_000_000).toString().padStart(6, "0");
}
 
function timingSafeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  const bufA = Buffer.from(a);
  const bufB = Buffer.from(b);
  return createHmac("sha256", "key").update(bufA).digest()
    .equals(createHmac("sha256", "key").update(bufB).digest());
}

Mã Dự Phòng#

Người dùng mất điện thoại. Luôn tạo mã dự phòng khi thiết lập MFA:

typescript
import { randomBytes, createHash } from "crypto";
 
function generateBackupCodes(count: number = 10): string[] {
  return Array.from({ length: count }, () =>
    randomBytes(4).toString("hex").toUpperCase() // Mã hex 8 ký tự
  );
}
 
async function storeBackupCodes(userId: string, codes: string[]) {
  // Hash mã trước khi lưu — xử lý chúng như mật khẩu
  const hashedCodes = codes.map((code) =>
    createHash("sha256").update(code).digest("hex")
  );
 
  await db.backupCode.createMany({
    data: hashedCodes.map((hash) => ({
      userId,
      codeHash: hash,
      used: false,
    })),
  });
 
  // Trả mã plaintext MỘT LẦN cho người dùng lưu
  // Sau đó, chúng ta chỉ có hash
  return codes;
}
 
async function verifyBackupCode(userId: string, code: string): Promise<boolean> {
  const codeHash = createHash("sha256")
    .update(code.toUpperCase().replace(/\s/g, ""))
    .digest("hex");
 
  const backupCode = await db.backupCode.findFirst({
    where: {
      userId,
      codeHash,
      used: false,
    },
  });
 
  if (!backupCode) return false;
 
  // Đánh dấu đã sử dụng — mỗi mã dự phòng hoạt động đúng một lần
  await db.backupCode.update({
    where: { id: backupCode.id },
    data: { used: true, usedAt: new Date() },
  });
 
  return true;
}

Luồng Khôi Phục#

Khôi phục MFA là phần hầu hết tutorial bỏ qua và hầu hết ứng dụng thực làm hỏng. Đây là những gì tôi triển khai:

  1. Chính: Mã TOTP từ ứng dụng authenticator
  2. Phụ: Một trong 10 mã dự phòng
  3. Phương án cuối: Khôi phục qua email với thời gian chờ 24 giờ và thông báo đến các kênh đã xác minh khác của người dùng

Thời gian chờ rất quan trọng. Nếu kẻ tấn công đã xâm phạm email người dùng, bạn không muốn cho họ tắt MFA ngay lập tức. Độ trễ 24 giờ cho người dùng hợp pháp thời gian phát hiện email và can thiệp.

typescript
async function initiateAccountRecovery(email: string) {
  const user = await db.user.findUnique({ where: { email } });
  if (!user) {
    // Không tiết lộ tài khoản có tồn tại hay không
    return { message: "If that email exists, we've sent recovery instructions." };
  }
 
  const recoveryToken = randomBytes(32).toString("hex");
  const tokenHash = createHash("sha256").update(recoveryToken).digest("hex");
 
  await db.recoveryRequest.create({
    data: {
      userId: user.id,
      tokenHash,
      expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 giờ
      status: "pending",
    },
  });
 
  // Gửi email với link khôi phục
  await sendEmail(email, {
    subject: "Account Recovery Request",
    body: `
      Một yêu cầu đã được thực hiện để tắt MFA trên tài khoản của bạn.
      Nếu đây là bạn, nhấp link bên dưới sau 24 giờ: ...
      Nếu đây KHÔNG phải bạn, vui lòng thay đổi mật khẩu ngay lập tức.
    `,
  });
 
  return { message: "If that email exists, we've sent recovery instructions." };
}

Pattern Authorization#

Authentication nói cho bạn ai là người nào. Authorization nói cho bạn họ được phép làm gì. Làm sai điều này là cách bạn lên tin tức.

RBAC vs ABAC#

RBAC (Role-Based Access Control): Người dùng có role, role có quyền. Đơn giản, dễ suy luận, hoạt động cho hầu hết ứng dụng.

typescript
// RBAC — kiểm tra role trực tiếp
type Role = "user" | "editor" | "admin" | "super_admin";
 
const ROLE_PERMISSIONS: Record<Role, string[]> = {
  user: ["read:own_profile", "update:own_profile", "read:posts"],
  editor: ["read:own_profile", "update:own_profile", "read:posts", "create:posts", "update:posts"],
  admin: [
    "read:own_profile", "update:own_profile",
    "read:posts", "create:posts", "update:posts", "delete:posts",
    "read:users", "update:users",
  ],
  super_admin: ["*"], // Cẩn thận với wildcard
};
 
function hasPermission(role: Role, permission: string): boolean {
  const permissions = ROLE_PERMISSIONS[role];
  return permissions.includes("*") || permissions.includes(permission);
}
 
// Sử dụng trong API route
export async function DELETE(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const session = await auth();
  if (!session?.user) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }
 
  if (!hasPermission(session.user.role as Role, "delete:posts")) {
    return Response.json({ error: "Forbidden" }, { status: 403 });
  }
 
  const { id } = await params;
  await db.post.delete({ where: { id } });
  return Response.json({ success: true });
}

ABAC (Attribute-Based Access Control): Quyền phụ thuộc vào thuộc tính của người dùng, tài nguyên và ngữ cảnh. Linh hoạt hơn nhưng phức tạp hơn.

typescript
// ABAC — khi RBAC không đủ
interface PolicyContext {
  user: {
    id: string;
    role: string;
    department: string;
    clearanceLevel: number;
  };
  resource: {
    type: string;
    ownerId: string;
    classification: string;
    department: string;
  };
  action: string;
  environment: {
    ipAddress: string;
    time: Date;
    mfaVerified: boolean;
  };
}
 
function evaluatePolicy(context: PolicyContext): boolean {
  const { user, resource, action, environment } = context;
 
  // Người dùng luôn có thể đọc tài nguyên riêng
  if (action === "read" && resource.ownerId === user.id) {
    return true;
  }
 
  // Admin có thể đọc bất kỳ tài nguyên nào trong phòng ban
  if (
    action === "read" &&
    user.role === "admin" &&
    user.department === resource.department
  ) {
    return true;
  }
 
  // Tài nguyên mật yêu cầu MFA và mức clearance tối thiểu
  if (resource.classification === "confidential") {
    if (!environment.mfaVerified) return false;
    if (user.clearanceLevel < 3) return false;
  }
 
  // Hành động phá hủy bị chặn ngoài giờ làm việc
  if (action === "delete") {
    const hour = environment.time.getHours();
    if (hour < 9 || hour > 17) return false;
  }
 
  return false; // Từ chối mặc định
}

Quy Tắc "Kiểm Tra Tại Ranh Giới"#

Đây là nguyên tắc authorization quan trọng nhất: kiểm tra quyền tại mọi ranh giới tin cậy, không chỉ ở cấp UI.

typescript
// XẤU — chỉ kiểm tra trong component
function DeleteButton({ post }: { post: Post }) {
  const { data: session } = useSession();
 
  // Điều này ẩn nút, nhưng không ngăn xóa
  if (session?.user?.role !== "admin") return null;
 
  return <button onClick={() => deletePost(post.id)}>Delete</button>;
}
 
// CŨNG XẤU — kiểm tra trong server action nhưng không trong API route
async function deletePostAction(postId: string) {
  const session = await auth();
  if (session?.user?.role !== "admin") throw new Error("Forbidden");
  await db.post.delete({ where: { id: postId } });
}
// Kẻ tấn công vẫn có thể gọi POST /api/posts/123 trực tiếp
 
// TỐT — kiểm tra tại mọi ranh giới
// 1. Ẩn nút trong UI (UX, không phải bảo mật)
// 2. Kiểm tra trong server action (phòng thủ chiều sâu)
// 3. Kiểm tra trong API route (ranh giới bảo mật thực)
// 4. Tùy chọn, kiểm tra trong middleware (bảo vệ cấp route)

Kiểm tra UI là cho trải nghiệm người dùng. Kiểm tra server là cho bảo mật. Không bao giờ chỉ dựa vào một trong hai.

Kiểm Tra Quyền Trong Next.js Middleware#

Middleware chạy trước mọi request khớp. Đó là nơi tốt cho kiểm soát truy cập hạt thô:

typescript
// "Người dùng này có được phép truy cập phần này không?"
// Kiểm tra chi tiết ("Người dùng này có thể chỉnh sửa BÀI VIẾT NÀY không?") thuộc về route handler
// vì middleware không dễ truy cập request body hoặc route params.
 
export default auth((req) => {
  const path = req.nextUrl.pathname;
  const role = req.auth?.user?.role;
 
  // Kiểm soát truy cập cấp route
  const routeAccess: Record<string, Role[]> = {
    "/admin": ["admin", "super_admin"],
    "/editor": ["editor", "admin", "super_admin"],
    "/dashboard": ["user", "editor", "admin", "super_admin"],
  };
 
  for (const [route, allowedRoles] of Object.entries(routeAccess)) {
    if (path.startsWith(route)) {
      if (!role || !allowedRoles.includes(role as Role)) {
        return NextResponse.redirect(new URL("/unauthorized", req.nextUrl));
      }
    }
  }
 
  return NextResponse.next();
});

Các Lỗ Hổng Phổ Biến#

Đây là các cuộc tấn công tôi thấy thường xuyên nhất trong codebase thực. Hiểu chúng là thiết yếu.

Session Fixation#

Cuộc tấn công: Kẻ tấn công tạo session hợp lệ trên trang web của bạn, rồi lừa nạn nhân sử dụng session ID đó (ví dụ, qua URL parameter hoặc bằng cách đặt cookie thông qua subdomain). Khi nạn nhân đăng nhập, session của kẻ tấn công giờ có người dùng đã xác thực.

Cách sửa: Luôn tái tạo session ID sau khi xác thực thành công. Không bao giờ cho session ID trước xác thực chuyển sang session sau xác thực.

typescript
async function login(credentials: { email: string; password: string }, request: Request) {
  const user = await verifyCredentials(credentials);
  if (!user) throw new Error("Invalid credentials");
 
  // QUAN TRỌNG: Xóa session cũ và tạo cái mới
  const oldSessionId = getSessionIdFromCookie(request);
  if (oldSessionId) {
    await redis.del(`session:${oldSessionId}`);
  }
 
  // Tạo session hoàn toàn mới với ID mới
  const newSessionId = await createSession(user.id, request);
  return newSessionId;
}

CSRF (Cross-Site Request Forgery)#

Cuộc tấn công: Người dùng đã đăng nhập vào trang web của bạn. Họ truy cập trang độc hại thực hiện request đến trang web của bạn. Vì cookie được gửi tự động, request được xác thực.

Cách sửa hiện đại: Cookie SameSite. Đặt SameSite: Lax (mặc định trong hầu hết trình duyệt hiện nay) ngăn cookie được gửi trên POST request cross-origin, bao phủ hầu hết kịch bản CSRF.

typescript
// SameSite=Lax bao phủ hầu hết kịch bản CSRF:
// - Chặn cookie trên POST, PUT, DELETE cross-origin
// - Cho phép cookie trên GET cross-origin (điều hướng top-level)
//   Điều này ổn vì GET request không nên có side effect
 
cookieStore.set("session_id", sessionId, {
  httpOnly: true,
  secure: true,
  sameSite: "lax",  // Đây là bảo vệ CSRF của bạn
  maxAge: 86400,
  path: "/",
});

Cho API chấp nhận JSON, bạn được bảo vệ thêm miễn phí: header Content-Type: application/json không thể được đặt bởi HTML form, và CORS ngăn JavaScript trên origin khác thực hiện request với header tùy chỉnh.

Nếu bạn cần đảm bảo mạnh hơn (ví dụ, bạn chấp nhận form submission), sử dụng pattern double-submit cookie hoặc synchronizer token. Auth.js xử lý điều này cho bạn.

Open Redirect Trong OAuth#

Cuộc tấn công: Kẻ tấn công tạo OAuth callback URL redirect đến trang web của họ sau xác thực: https://yourapp.com/callback?redirect_to=https://evil.com/steal-token

Nếu callback handler mù quáng redirect đến parameter redirect_to, người dùng kết thúc trên trang web kẻ tấn công, có thể với token trong URL.

typescript
// DỄ BỊ TẤN CÔNG
async function handleCallback(request: Request) {
  const url = new URL(request.url);
  const redirectTo = url.searchParams.get("redirect_to") ?? "/";
  // ... xác thực người dùng ...
  return Response.redirect(redirectTo); // Có thể là https://evil.com!
}
 
// AN TOÀN
async function handleCallback(request: Request) {
  const url = new URL(request.url);
  const redirectTo = url.searchParams.get("redirect_to") ?? "/";
 
  // Validate redirect URL
  const safeRedirect = sanitizeRedirectUrl(redirectTo, request.url);
  // ... xác thực người dùng ...
  return Response.redirect(safeRedirect);
}
 
function sanitizeRedirectUrl(redirect: string, baseUrl: string): string {
  try {
    const url = new URL(redirect, baseUrl);
    const base = new URL(baseUrl);
 
    // Chỉ cho phép redirect đến cùng origin
    if (url.origin !== base.origin) {
      return "/";
    }
 
    // Chỉ cho phép redirect path (không javascript: hoặc data: URI)
    if (!url.pathname.startsWith("/")) {
      return "/";
    }
 
    return url.pathname + url.search;
  } catch {
    return "/";
  }
}

Rò Rỉ Token Qua Referrer#

Nếu bạn đặt token trong URL (đừng), chúng sẽ rò rỉ qua header Referer khi người dùng nhấp link. Điều này đã gây vi phạm thực tế, bao gồm tại GitHub.

Quy tắc:

  • Không bao giờ đặt token trong URL query parameter cho xác thực
  • Đặt Referrer-Policy: strict-origin-when-cross-origin (hoặc nghiêm ngặt hơn)
  • Nếu bạn phải đặt token trong URL (ví dụ, link xác minh email), làm chúng sử dụng một lần và ngắn hạn
typescript
// Trong Next.js middleware hoặc layout
const headers = new Headers();
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");

JWT Key Injection#

Một cuộc tấn công ít được biết: một số thư viện JWT hỗ trợ header jwk hoặc jku nói cho verifier biết tìm public key ở đâu. Kẻ tấn công có thể:

  1. Tạo cặp key riêng
  2. Tạo JWT với payload và ký bằng private key của họ
  3. Đặt header jwk trỏ đến public key của họ

Nếu thư viện mù quáng fetch và sử dụng key từ header jwk, chữ ký xác minh hợp lệ. Cách sửa: không bao giờ cho phép token chỉ định verification key riêng. Luôn sử dụng key từ cấu hình riêng của bạn.

Stack Auth Của Tôi Năm 2026#

Sau nhiều năm xây dựng hệ thống xác thực, đây là những gì tôi thực sự sử dụng hôm nay.

Cho Hầu Hết Dự Án: Auth.js + PostgreSQL + Passkeys#

Đây là stack mặc định của tôi cho dự án mới:

  • Auth.js (v5) cho phần nặng: OAuth provider, quản lý session, CSRF, database adapter
  • PostgreSQL với Prisma adapter cho lưu trữ session và account
  • Passkeys qua SimpleWebAuthn làm phương thức đăng nhập chính cho người dùng mới
  • Email/mật khẩu làm phương án dự phòng cho người dùng không thể sử dụng passkey
  • TOTP MFA làm yếu tố thứ hai cho đăng nhập dựa trên mật khẩu

Chiến lược session được hỗ trợ bởi database (không phải JWT), cho tôi thu hồi tức thì và quản lý session đơn giản.

typescript
// Đây là auth.ts điển hình cho dự án mới
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Passkey from "next-auth/providers/passkey";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
 
export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  session: { strategy: "database" },
  providers: [
    Google,
    GitHub,
    Passkey({
      // Auth.js v5 có hỗ trợ passkey tích hợp
      // Sử dụng SimpleWebAuthn bên dưới
    }),
  ],
  experimental: {
    enableWebAuthn: true,
  },
});

Khi Nào Sử Dụng Clerk hoặc Auth0 Thay Vào#

Tôi chọn managed auth provider khi:

  • Dự án cần enterprise SSO (SAML, SCIM). Triển khai SAML đúng cách là dự án nhiều tháng. Clerk làm ngay từ đầu.
  • Team không có chuyên môn bảo mật. Nếu không ai trong team giải thích được PKCE, họ không nên xây auth từ đầu.
  • Thời gian ra thị trường quan trọng hơn chi phí. Auth.js miễn phí nhưng mất vài ngày để setup đúng cách. Clerk mất một buổi chiều.
  • Bạn cần đảm bảo tuân thủ (SOC 2, HIPAA). Provider quản lý xử lý chứng nhận tuân thủ.

Các đánh đổi của managed provider:

  • Chi phí: Clerk tính phí theo monthly active user. Ở quy mô lớn, điều này cộng dồn.
  • Vendor lock-in: Di chuyển khỏi Clerk hoặc Auth0 rất đau đớn. Bảng user của bạn ở trên server của họ.
  • Giới hạn tùy chỉnh: Nếu flow auth của bạn khác thường, bạn sẽ chiến đấu với ý kiến của provider.
  • Độ trễ: Mỗi kiểm tra auth đi đến API bên thứ ba. Với database session, đó là query local.

Những Gì Tôi Tránh#

  • Tự tạo crypto. Tôi sử dụng jose cho JWT, @simplewebauthn/server cho passkey, bcrypt hoặc argon2 cho mật khẩu. Không bao giờ tự tạo.
  • Lưu mật khẩu bằng SHA256. Sử dụng bcrypt (cost factor 12+) hoặc argon2id. SHA256 quá nhanh — kẻ tấn công có thể thử hàng tỷ hash mỗi giây với GPU.
  • Access token dài hạn. Tối đa 15 phút. Sử dụng refresh token rotation cho session dài hơn.
  • Symmetric secret cho xác minh xuyên service. Nếu nhiều service cần xác minh token, sử dụng RS256 với cặp public/private key.
  • Session ID tùy chỉnh với entropy không đủ. Sử dụng crypto.randomBytes(32) tối thiểu. UUID v4 chấp nhận được nhưng ít entropy hơn byte ngẫu nhiên thuần.

Hash Mật Khẩu: Cách Đúng#

Vì chúng ta đã đề cập — đây là cách hash mật khẩu đúng trong 2026:

typescript
import { hash, verify } from "@node-rs/argon2";
 
// Argon2id là thuật toán được khuyến nghị
// Đây là giá trị mặc định hợp lý cho ứng dụng web
async function hashPassword(password: string): Promise<string> {
  return hash(password, {
    memoryCost: 65536,  // 64 MB
    timeCost: 3,        // 3 vòng lặp
    parallelism: 4,     // 4 thread
  });
}
 
async function verifyPassword(
  password: string,
  hashedPassword: string
): Promise<boolean> {
  try {
    return await verify(hashedPassword, password);
  } catch {
    return false;
  }
}

Tại sao argon2id hơn bcrypt? Argon2id yêu cầu nhiều bộ nhớ, nghĩa là tấn công nó yêu cầu không chỉ sức mạnh CPU mà còn lượng lớn RAM. Điều này làm tấn công GPU và ASIC đắt đỏ hơn đáng kể. Bcrypt vẫn ổn — nó không bị phá — nhưng argon2id là lựa chọn tốt hơn cho dự án mới.

Danh Sách Kiểm Tra Bảo Mật#

Trước khi phát hành bất kỳ hệ thống xác thực nào, hãy xác minh:

  • Mật khẩu được hash bằng argon2id hoặc bcrypt (cost 12+)
  • Session được tái tạo sau đăng nhập (ngăn session fixation)
  • Cookie là HttpOnly, Secure, SameSite=Lax hoặc Strict
  • JWT chỉ định thuật toán rõ ràng (không bao giờ tin header alg)
  • Access token hết hạn trong 15 phút hoặc ít hơn
  • Refresh token rotation được triển khai với phát hiện tái sử dụng
  • Tham số state OAuth được xác minh (bảo vệ CSRF)
  • Redirect URL được validate so với danh sách cho phép
  • Rate limiting được áp dụng cho endpoint đăng nhập, đăng ký và reset mật khẩu
  • Số lần đăng nhập thất bại được log với IP nhưng không với mật khẩu
  • Khóa tài khoản sau N lần thử thất bại (với delay tăng dần, không khóa vĩnh viễn)
  • Token reset mật khẩu sử dụng một lần và hết hạn trong 1 giờ
  • Mã dự phòng MFA được hash như mật khẩu
  • CORS được cấu hình chỉ cho phép origin đã biết
  • Header Referrer-Policy được đặt
  • Không có dữ liệu nhạy cảm trong JWT payload (chúng có thể đọc được bởi bất kỳ ai)
  • Counter WebAuthn được xác minh và cập nhật (ngăn sao chép credential)

Danh sách này không toàn diện, nhưng nó bao phủ các lỗ hổng tôi thấy thường xuyên nhất trong hệ thống production.

Kết Luận#

Xác thực là một trong những lĩnh vực mà cảnh quan liên tục phát triển, nhưng các nguyên tắc cơ bản vẫn giữ nguyên: xác minh danh tính, phát hành credential tối thiểu cần thiết, kiểm tra quyền tại mọi ranh giới, và giả định vi phạm.

Sự thay đổi lớn nhất năm 2026 là passkey trở thành xu hướng chính. Hỗ trợ trình duyệt là phổ quát, hỗ trợ nền tảng (iCloud Keychain, Google Password Manager) làm UX mượt mà, và các thuộc tính bảo mật thực sự vượt trội so với bất cứ thứ gì chúng ta có trước đây. Nếu bạn đang xây ứng dụng mới, hãy biến passkey thành phương thức đăng nhập chính và coi mật khẩu là phương án dự phòng.

Sự thay đổi lớn thứ hai là tự xây auth đã trở nên khó biện minh hơn. Auth.js v5, Clerk và các giải pháp tương tự xử lý phần khó đúng cách. Lý do duy nhất để đi custom là khi yêu cầu của bạn thực sự không phù hợp với bất kỳ giải pháp hiện có nào — và điều đó hiếm hơn hầu hết developer nghĩ.

Dù bạn chọn gì, hãy test auth như cách kẻ tấn công sẽ làm. Thử replay token, giả mạo chữ ký, truy cập route bạn không nên, và thao túng redirect URL. Những bug bạn tìm thấy trước khi ra mắt là những bug không lên tin tức.

Bài viết liên quan