Next.js 16 from Zero to Senior · Part 9 — Auth, Sessions & Security
Authenticate properly in the App Router: sessions vs JWTs, secure httpOnly cookies, a Data Access Layer, protecting pages and Server Actions, proxy.ts for redirects, and the security headers and practices every app needs.
Auth là nơi Server Components, Server Actions, route handler, và proxy.ts gặp nhau — và là nơi một giả định sai làm rò rỉ dữ liệu user. Phần này trình bày các mẫu mà chính Next.js khuyến nghị, với tư duy bảo mật của một senior.
Quy tắc vàng: đừng bao giờ tin client, và kiểm tra phân quyền càng gần dữ liệu càng tốt.
1. Session vs JWT
Hai cách phổ biến để nhớ một user đã đăng nhập:
- Session có trạng thái — lưu bản ghi session phía server (DB/Redis), đưa trình duyệt một session ID mờ trong cookie. Dễ thu hồi; cần tra cứu mỗi request.
- JWT không trạng thái — ký một token chứa claim; server xác minh chữ ký không cần tra cứu. Nhanh; khó thu hồi trước khi hết hạn.
Cả hai sống trong một cookie httpOnly để JavaScript không đọc được (chống XSS). Với phần lớn app, bắt đầu bằng session — thu hồi quan trọng.
2. Cookie an toàn
Lưu gì cũng set cookie với cờ an toàn:
'use server';
import { cookies } from 'next/headers';
export async function setSession(token: string) {
const cookieStore = await cookies();
cookieStore.set('session', token, {
httpOnly: true, // JS can't read it → blunts XSS token theft
secure: true, // HTTPS only
sameSite: 'lax', // sent on top-level navigations, not cross-site POSTs → CSRF defense
path: '/',
maxAge: 60 * 60 * 24 * 7, // 7 days
});
}
httpOnly + secure + sameSite là nền tảng mọi cookie session cần.
3. Các helper session
Tập trung logic session trong một module server-only:
// lib/session.ts
import 'server-only';
import { cookies } from 'next/headers';
import { cache } from 'react';
export const getSession = cache(async () => {
const token = (await cookies()).get('session')?.value;
if (!token) return null;
try {
return await verifyAndLoadUser(token); // verify signature / look up session row
} catch {
return null;
}
});
Bọc trong cache() của React nghĩa là nhiều component trong một lần render dùng chung một lần xác minh (Phần 3). server-only đảm bảo cái này không bao giờ gửi về trình duyệt.
4. Tầng truy cập dữ liệu (DAL)
Mẫu senior mà Next.js khuyến nghị: đặt phân quyền cạnh truy cập dữ liệu, không rải khắp các page. Mỗi hàm dữ liệu tự xác minh session:
// lib/dal.ts
import 'server-only';
import { getSession } from './session';
export async function requireUser() {
const user = await getSession();
if (!user) throw new Error('Unauthorized');
return user;
}
export async function getMyInvoices() {
const user = await requireUser(); // auth check at the data boundary
return db.invoice.findMany({ where: { userId: user.id } }); // scoped to the owner
}
Giờ không thể fetch invoice mà không qua kiểm tra auth, dù page nào gọi. An toàn hơn nhiều so với chỉ dựa vào kiểm tra ở UI hay middleware.
5. Bảo vệ trang
Trong một page Server Component, gọi DAL — nó chuyển hướng hoặc ném nếu không được phép:
// app/dashboard/page.tsx
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/session';
export default async function Dashboard() {
const user = await getSession();
if (!user) redirect('/login');
const invoices = await getMyInvoices(); // already scoped to user
return <InvoiceList invoices={invoices} />;
}
Đừng dựa vào
proxy.tslàm cổng duy nhất. Nó là bộ lọc đầu tiên nhanh, nhưng kiểm tra phân quyền thật phải nằm ở tầng dữ liệu, vì không phải đường dữ liệu nào cũng đi qua proxy.
6. proxy.ts cho chuyển hướng thô
Dùng proxy.ts (Phần 7) cho chuyển hướng rẻ, lạc quan cải thiện UX — đẩy user rõ ràng chưa đăng nhập khỏi vùng được bảo vệ trước cả khi page render:
// proxy.ts
import { NextResponse, type NextRequest } from 'next/server';
export function proxy(request: NextRequest) {
const hasSession = Boolean(request.cookies.get('session'));
const { pathname } = request.nextUrl;
if (pathname.startsWith('/dashboard') && !hasSession) {
const url = new URL('/login', request.url);
url.searchParams.set('from', pathname); // remember where they wanted to go
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = { matcher: ['/dashboard/:path*'] };
Đây là kiểm tra lạc quan (chỉ “có cookie không?”), không phải xác minh thật — việc đó vẫn ở DAL.
7. Bảo mật Server Actions & route handler
Nhớ từ Phần 6: action và handler là endpoint công khai. Xác thực và phân quyền bên trong chúng:
'use server';
import { requireUser } from '@/lib/dal';
import { revalidateTag } from 'next/cache';
export async function deleteInvoice(id: string) {
const user = await requireUser();
const invoice = await db.invoice.findUnique({ where: { id } });
if (invoice?.userId !== user.id) throw new Error('Forbidden'); // ownership check
await db.invoice.delete({ where: { id } });
revalidateTag('invoices');
}
Đừng bao giờ giả định “nút bị ẩn nên họ không gọi được” — họ gọi được.
8. Luồng đăng nhập & đăng xuất
// app/login/actions.ts
'use server';
import { redirect } from 'next/navigation';
import { cookies } from 'next/headers';
import { z } from 'zod';
const Login = z.object({ email: z.string().email(), password: z.string().min(8) });
export async function login(_: unknown, formData: FormData) {
const parsed = Login.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: 'Invalid credentials' };
const user = await verifyCredentials(parsed.data); // constant-time compare server-side
if (!user) return { error: 'Invalid credentials' }; // don't reveal which field was wrong
const token = await createSession(user.id);
(await cookies()).set('session', token, { httpOnly: true, secure: true, sameSite: 'lax', path: '/' });
redirect('/dashboard');
}
export async function logout() {
(await cookies()).delete('session');
await invalidateServerSession(); // kill the server-side record too
redirect('/login');
}
Khi đăng xuất, xóa cookie và vô hiệu hóa session server — và xóa mọi dữ liệu user đã cache (nhớ các pitfall caching/auth) để nút back không hiện trang riêng tư cũ.
Cho production, ưu tiên một thư viện đã được kiểm chứng (Auth.js, Clerk, Lucia, WorkOS) hơn là tự viết crypto. Các mẫu trên là thứ những thư viện đó nối sẵn cho bạn.
9. Security header & CSP
Đặt security header toàn cục trong next.config.ts:
// next.config.ts
const nextConfig: NextConfig = {
async headers() {
return [
{
source: '/:path*',
headers: [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
],
},
];
},
};
Một Content-Security-Policy là phòng thủ XSS mạnh nhất; Next.js hỗ trợ CSP dựa trên nonce qua proxy.ts cho script. (Nếu bạn đã đọc series Web Security trên blog này, áp dụng tất cả ở đây.)
10. Biến môi trường
- Secret nằm trong
.env.local(gitignore) và chỉ-server theo mặc định. - Chỉ biến có tiền tố
NEXT_PUBLIC_mới lộ ra trình duyệt — đừng bao giờ để secret ở đó.
const dbUrl = process.env.DATABASE_URL; // server-only ✅
const analyticsId = process.env.NEXT_PUBLIC_GA; // shipped to browser ⚠️ (non-secret only)
Validate env lúc khởi động để một secret thiếu fail rõ ràng, không phải lúc 2 giờ sáng.
11. Bài tập
-
Cookie session: dựng action
login/logoutset/xóa cookiesessionhttpOnly; xác nhận trong DevTools cookie không đọc được từdocument.cookie. -
DAL: viết
requireUser()vàgetMyData()giới hạn kết quả theo user; gọi từ một page. -
Bảo vệ page: chuyển hướng user chưa xác thực từ
/dashboardtới/login?from=/dashboard, rồi quay lại sau đăng nhập. -
Kiểm tra sở hữu: thêm kiểm tra sở hữu vào action
deleteInvoice; thử (và thất bại) xóa invoice của user khác bằng cách gọi action với id lạ. -
Header: thêm các security header trên và xác minh bằng
curl -I http://localhost:3000. -
Săn rò rỉ: import một module secret
server-onlyvào một Client Component và xem build fail — rồi sửa.
Phần tiếp theo
Bạn xác thực được user, lưu session trong cookie an toàn, ép phân quyền ở tầng dữ liệu, chặn route bằng proxy.ts, và làm cứng app bằng header và quản lý env kỷ luật.
Phần 10 là hồi kết: production — ngân sách hiệu năng, phân tích bundle, test với Vitest và Playwright, deploy lên Vercel và tự host với Docker, debug với Next Devtools MCP, và một capstone gói cả series lại.