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 is where Server Components, Server Actions, route handlers, and proxy.ts all meet — and where a single wrong assumption leaks user data {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}. This part shows the patterns Next.js itself recommends, with the security mindset of a senior {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}.
The golden rule {Quy tắc vàng}: never trust the client, and check authorization as close to the data as possible {đừ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. Sessions vs JWTs {Session vs JWT}
Two common ways to remember a logged-in user {Hai cách phổ biến để nhớ một user đã đăng nhập}:
- Stateful sessions — store a session record server-side (DB/Redis), give the browser an opaque session ID in a cookie. Easy to revoke; needs a lookup per request {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}.
- Stateless JWTs — sign a token containing the claims; the server verifies the signature without a lookup. Fast; harder to revoke before expiry {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}.
Both live in an httpOnly cookie so JavaScript can’t read them (XSS protection) {Cả hai sống trong một cookie httpOnly để JavaScript không đọc được (chống XSS)}. For most apps, start with sessions — revocation matters {Với phần lớn app, bắt đầu bằng session — thu hồi quan trọng}.
2. Secure cookies {Cookie an toàn}
Whatever you store, set the cookie with safe flags {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 is the baseline every session cookie needs {httpOnly + secure + sameSite là nền tảng mọi cookie session cần}.
3. The session helpers {Các helper session}
Centralize session logic in a server-only module {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;
}
});
Wrapping in React’s cache() means many components in one render share a single verification (Part 3) {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 guarantees this never ships to the browser {server-only đảm bảo cái này không bao giờ gửi về trình duyệt}.
4. The Data Access Layer (DAL) {Tầng truy cập dữ liệu (DAL)}
The senior pattern Next.js recommends: put authorization next to data access, not scattered across pages {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}. Every data function verifies the session itself {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
}
Now it’s impossible to fetch invoices without an auth check, no matter which page calls it {Giờ không thể fetch invoice mà không qua kiểm tra auth, dù page nào gọi}. This is far safer than relying on UI-level or middleware-level checks alone {An toàn hơn nhiều so với chỉ dựa vào kiểm tra ở UI hay middleware}.
5. Protecting pages {Bảo vệ trang}
In a Server Component page, call your DAL — it redirects or throws if unauthorized {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} />;
}
Don’t rely on
proxy.tsas your only gate. It’s a fast first filter, but the real authorization check must live at the data layer, because not every data path goes through the proxy {Đừng dựa vàoproxy.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 for coarse redirects {proxy.ts cho chuyển hướng thô}
Use proxy.ts (Part 7) for cheap, optimistic redirects that improve UX — bounce obviously-logged-out users away from protected areas before the page even renders {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*'] };
This is an optimistic check (just “is there a cookie?”), not real verification — that still happens in the DAL {Đâ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. Securing Server Actions & route handlers {Bảo mật Server Actions & route handler}
Recall from Part 6: actions and handlers are public endpoints {Nhớ từ Phần 6: action và handler là endpoint công khai}. Authenticate and authorize inside them {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');
}
Never assume “the button was hidden, so they can’t call this” — they can {Đừng bao giờ giả định “nút bị ẩn nên họ không gọi được” — họ gọi được}.
8. The login & logout flow {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');
}
On logout, delete the cookie and invalidate the server session — and clear any cached user data (recall the caching/auth pitfalls) so the back button can’t show stale private pages {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ũ}.
For production, prefer a vetted library (Auth.js, Clerk, Lucia, WorkOS) over hand-rolling crypto. The patterns above are what those libraries wire up for you {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 headers & CSP {Security header & CSP}
Set security headers globally in next.config.ts {Đặ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' },
],
},
];
},
};
A Content-Security-Policy is the strongest XSS defense; Next.js supports nonce-based CSP via proxy.ts for scripts {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}. (If you read the Web Security series on this blog, apply all of it here.) {(Nếu bạn đã đọc series Web Security trên blog này, áp dụng tất cả ở đây.)}
10. Environment variables {Biến môi trường}
- Secrets go in
.env.local(gitignored) and are server-only by default {Secret nằm trong.env.local(gitignore) và chỉ-server theo mặc định}. - Only variables prefixed
NEXT_PUBLIC_are exposed to the browser — never put secrets there {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 at startup so a missing secret fails loudly, not at 2am {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. Exercises {Bài tập}
-
Session cookie {Cookie session}: build
login/logoutServer Actions that set/delete an httpOnlysessioncookie; confirm in DevTools the cookie is not readable fromdocument.cookie{dựng actionlogin/logoutset/xóa cookiesessionhttpOnly; xác nhận trong DevTools cookie không đọc được từdocument.cookie}. -
DAL {DAL}: write
requireUser()and agetMyData()that scopes results to the user; call it from a page {viếtrequireUser()vàgetMyData()giới hạn kết quả theo user; gọi từ một page}. -
Protect a page {Bảo vệ page}: redirect unauthenticated users from
/dashboardto/login?from=/dashboard, then redirect back after login {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}. -
Ownership check {Kiểm tra sở hữu}: add an ownership check to a
deleteInvoiceaction; try (and fail) to delete another user’s invoice by calling the action with a foreign id {thêm kiểm tra sở hữu vào actiondeleteInvoice; 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ạ}. -
Headers {Header}: add the security headers above and verify them with
curl -I http://localhost:3000{thêm các security header trên và xác minh bằngcurl -I http://localhost:3000}. -
Leak hunt {Săn rò rỉ}: import a
server-onlysecrets module into a Client Component and watch the build fail — then fix it {import một module secretserver-onlyvào một Client Component và xem build fail — rồi sửa}.
What’s next {Phần tiếp theo}
You can authenticate users, store sessions in secure cookies, enforce authorization at the data layer, gate routes with proxy.ts, and harden the app with headers and disciplined env handling {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}.
Part 10 is the finale: production — performance budgets, bundle analysis, testing with Vitest and Playwright, deploying to Vercel and self-hosting with Docker, debugging with the Next Devtools MCP, and a capstone that ties the whole series together {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}.