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.
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.
Đâ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.
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.
// 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:
Các nhược điểm cũng thực:
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ữ.
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:
Các nhược điểm — và đây là những thứ mọi người lướt qua:
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.
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.
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:
{
"alg": "RS256",
"typ": "JWT"
}Payload — chứa các claims. Claims chuẩn có tên ngắn:
{
"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.
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.
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.
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:
alg từ headeralg: "none", bỏ qua xác minh chữ ký hoàn toànKẻ 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.
// 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ó.
// 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.
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ể:
alg: "HS256"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ó.
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:
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 };
}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ũ:
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 };
}Xem xét kịch bản này:
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 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.
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).
Đâ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.
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();
}// 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,
};
}Mỗi provider OAuth/OIDC expose những endpoint này:
Tham số state ngăn tấn công CSRF trên OAuth callback. Không có nó:
https://yourapp.com/callback?code=ATTACKER_CODEVớ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 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.
// 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",
},
});// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;// 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",
],
};// 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>
);
}"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>
);
}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 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ị:
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.
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.
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.
// 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 };
}// 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 };
}// 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");
}
}
}Hai thuật ngữ bạn sẽ gặp:
attestationType: "none".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 là giao thức đằng sau Google Authenticator, Authy và 1Password. Nó hoạt động bằng cách:
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`;
}// 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());
}Người dùng mất điện thoại. Luôn tạo mã dự phòng khi thiết lập MFA:
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;
}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:
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.
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." };
}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 (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.
// 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.
// 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
}Đâ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.
// 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.
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ô:
// "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();
});Đâ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.
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.
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;
}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.
// 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.
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.
// 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 "/";
}
}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:
Referrer-Policy: strict-origin-when-cross-origin (hoặc nghiêm ngặt hơn)// Trong Next.js middleware hoặc layout
const headers = new Headers();
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");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ể:
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.
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.
Đây là stack mặc định của tôi cho dự án mới:
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.
// Đâ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,
},
});Tôi chọn managed auth provider khi:
Các đánh đổi của managed provider:
jose cho JWT, @simplewebauthn/server cho passkey, bcrypt hoặc argon2 cho mật khẩu. Không bao giờ tự tạo.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.Vì chúng ta đã đề cập — đây là cách hash mật khẩu đúng trong 2026:
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.
Trước khi phát hành bất kỳ hệ thống xác thực nào, hãy xác minh:
HttpOnly, Secure, SameSite=Lax hoặc Strictalg)Referrer-Policy được đặtDanh 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.
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.