Giới thiệu
6 phút đọc15 tháng 6, 2026391

Auth trong Next.js: Middleware, Cookie và mấy thứ không ai nói cho bạn

Implement authentication flow trong Next.js từ middleware đến protected routes, cookie-based session và chọn auth library phù hợp.

N

Nguyễn Nhật Long

@nguyennhatlong1303

Auth trong Next.js: Middleware, Cookie và mấy thứ không ai nói cho bạn

Bài này mình sẽ nói thẳng vào vấn đề auth là thứ mà hầu hết project nào cũng cần, nhưng làm đúng cách thì không phải ai cũng làm. Next.js có một số pattern khá hay để handle cái này, và middleware chính là trung tâm của toàn bộ flow.

Middleware trong Next.js hoạt động như thế nào

Điểm đầu tiên bạn cần hiểu: middleware trong Next.js chạy trên Edge Runtime, nghĩa là nó chạy trước khi request chạm vào bất kỳ route handler hay server component nào. Đây là lý do tại sao nó là chỗ lý tưởng để làm auth check bạn chặn ngay từ đầu, không cần render gì cả.

File middleware.ts đặt ở root project (cùng cấp với app/ hoặc pages/), và Next.js tự động pick up nó:

TypeScript
1// middleware.ts
2import { NextResponse } from 'next/server'
3import type { NextRequest } from 'next/server'
4
5export function middleware(request: NextRequest) {
6 const { pathname } = request.nextUrl
7
8 const protectedPaths = ['/dashboard', '/admin', '/settings']
9 const isProtected = protectedPaths.some(p => pathname.startsWith(p))
10
11 if (isProtected) {
12 const token = request.cookies.get('session')
13 if (!token) {
14 return NextResponse.redirect(new URL('/login', request.url))
15 }
16 }
17
18 return NextResponse.next()
19}
20
21export const config = {
22 matcher: [
23 '/((?!_next/static|_next/image|favicon.ico|api/public).*)',
24 ],
25}

Cái matcher config này quan trọng lắm nếu bạn không set, middleware sẽ chạy cho mọi request kể cả static assets. Mình đã từng thấy project bị chậm vì quên config cái này, middleware chạy cả cho từng file .css, .js của Next.js. Anh em lưu ý điểm này.

Ngoài auth, middleware còn dùng được cho: i18n redirect (detect ngôn ngữ từ header), A/B testing (rewrite URL dựa theo cookie experiment), bot detection, rate limiting ở edge level.

Cookie-based Auth Flow thực tế

Cách phổ biến nhất và an toàn nhất là dùng httpOnly cookie để lưu session. Tại sao? Vì JavaScript client-side không đọc được cookie này, nên XSS attack không steal được token của user.

Flow login với Server Action:

TypeScript
1// app/actions/auth.ts
2'use server'
3
4import { cookies } from 'next/headers'
5import { redirect } from 'next/navigation'
6import { SignJWT } from 'jose'
7
8export async function login(formData: FormData) {
9 const email = formData.get('email') as string
10 const password = formData.get('password') as string
11
12 // Verify credentials với database
13 const user = await verifyCredentials(email, password)
14 if (!user) {
15 return { error: 'Invalid credentials' }
16 }
17
18 // Tạo JWT
19 const token = await new SignJWT({
20 userId: user.id,
21 role: user.role
22 })
23 .setProtectedHeader({ alg: 'HS256' })
24 .setExpirationTime('7d')
25 .sign(new TextEncoder().encode(process.env.JWT_SECRET))
26
27 // Set httpOnly cookie
28 cookies().set('session', token, {
29 httpOnly: true,
30 secure: process.env.NODE_ENV === 'production',
31 sameSite: 'lax',
32 maxAge: 60 * 60 * 24 * 7, // 7 ngày
33 path: '/'
34 })
35
36 redirect('/dashboard')
37}
38
39export async function logout() {
40 cookies().delete('session')
41 redirect('/login')
42}

Mình dùng jose library thay vì jsonwebtokenjose support Edge Runtime quan trọng nếu bạn muốn verify JWT ngay trong middleware.

Verify token trong Middleware

Khi đã có token trong cookie, middleware cần verify nó thực sự hợp lệ, không chỉ check tồn tại:

TypeScript
1// middleware.ts version đầy đủ hơn
2import { jwtVerify } from 'jose'
3
4async function verifyToken(token: string) {
5 try {
6 const { payload } = await jwtVerify(
7 token,
8 new TextEncoder().encode(process.env.JWT_SECRET)
9 )
10 return payload
11 } catch {
12 return null
13 }
14}
15
16export async function middleware(request: NextRequest) {
17 const { pathname } = request.nextUrl
18 const token = request.cookies.get('session')?.value
19
20 // Admin routes check thêm role
21 if (pathname.startsWith('/admin')) {
22 if (!token) return NextResponse.redirect(new URL('/login', request.url))
23
24 const payload = await verifyToken(token)
25 if (!payload || payload.role !== 'admin') {
26 return NextResponse.redirect(new URL('/403', request.url))
27 }
28 }
29
30 // Dashboard và các protected routes thông thường
31 if (pathname.startsWith('/dashboard')) {
32 if (!token) return NextResponse.redirect(new URL('/login', request.url))
33
34 const payload = await verifyToken(token)
35 if (!payload) {
36 // Token expired hoặc invalid clear cookie và redirect
37 const response = NextResponse.redirect(new URL('/login', request.url))
38 response.cookies.delete('session')
39 return response
40 }
41 }
42
43 return NextResponse.next()
44}

Theo kinh nghiệm của mình, đừng chỉ check token tồn tại mà bỏ qua bước verify đây là lỗi khá phổ biến. Token có thể đã expired hoặc bị tamper, nếu không verify thì user vẫn vào được protected route với token rác.

Role-based Access Control

Khi project lớn dần, bạn sẽ cần phân quyền chi tiết hơn. Mình thường encode role vào JWT payload và check trong middleware:

TypeScript
1// Trong JWT payload
2{
3 userId: 'user_123',
4 role: 'admin', // 'admin' | 'editor' | 'viewer'
5 permissions: ['read:posts', 'write:posts', 'delete:posts']
6}

Trong Server Component, bạn cũng có thể read session để show/hide UI:

TypeScript
1// app/dashboard/layout.tsx
2import { cookies } from 'next/headers'
3import { jwtVerify } from 'jose'
4
5async function getSession() {
6 const token = cookies().get('session')?.value
7 if (!token) return null
8
9 try {
10 const { payload } = await jwtVerify(
11 token,
12 new TextEncoder().encode(process.env.JWT_SECRET)
13 )
14 return payload
15 } catch {
16 return null
17 }
18}
19
20export default async function DashboardLayout({ children }) {
21 const session = await getSession()
22
23 return (
24 <div>
25 <nav>
26 {session?.role === 'admin' && (
27 <a href="/admin">Admin Panel</a>
28 )}
29 </nav>
30 {children}
31 </div>
32 )
33}

Chọn Auth Library nào?

Nếu bạn không muốn tự implement từ đầu, có khá nhiều lựa chọn:

Mình thấy cái này hay ở chỗ: Clerk cực kỳ developer-friendly nếu bạn cần ship nhanh setup chỉ mất khoảng 15 phút, có UI component sẵn, handle MFA, magic link, passkey đủ thứ. Nhưng nếu project có yêu cầu data residency (không được lưu data user ở nước ngoài) thì bắt buộc phải self-hosted.

LibraryTypeOAuthDatabaseĐộ phức tạpPhù hợp khi
**NextAuth.js (Auth.js)**Self-hosted✅ 50+ providersAdapter-basedTrung bìnhCần OAuth nhanh, project vừa
**Lucia**Self-hostedManualDatabase-agnosticCao hơnMuốn control hoàn toàn
**Clerk**Managed SaaS✅ Built-inManagedThấpStartup, MVP, không muốn lo auth
**Supabase Auth**Managed SaaS✅ Built-inSupabase onlyThấpĐã dùng Supabase
**Custom JWT**Self-hostedManualTự chọnCao nhấtEnterprise, requirements đặc thù

NextAuth.js là lựa chọn an toàn cho hầu hết trường hợp community lớn, docs tốt, support nhiều OAuth provider. Chỉ cần lưu ý version 5 (Auth.js) đang trong beta và có breaking changes so với v4.

Lucia thì mình chưa dùng production nhưng đọc qua thấy approach rất clean, phù hợp nếu bạn muốn hiểu rõ từng layer thay vì dùng magic.

Security Mấy thứ hay bị bỏ qua

Implement auth xong rồi nhưng bỏ qua security là thứ mình thấy khá thường xuyên trong code review. Một số thứ cần check:

CSRF Protection: Next.js Server Actions đã có built-in CSRF protection từ v14 (check Origin header), nhưng nếu bạn dùng API route thuần thì cần tự handle. Pattern phổ biến là double-submit cookie.

Rate Limiting cho login endpoint brute force attack là thứ rất thực tế:

TypeScript
1// middleware.ts thêm rate limiting
2import { Ratelimit } from '@upstash/ratelimit'
3import { Redis } from '@upstash/redis'
4
5const ratelimit = new Ratelimit({
6 redis: Redis.fromEnv(),
7 limiter: Ratelimit.slidingWindow(5, '1 m'), // 5 requests/phút
8})
9
10// Trong middleware, apply cho /api/login
11if (pathname === '/api/login') {
12 const ip = request.ip ?? '127.0.0.1'
13 const { success } = await ratelimit.limit(ip)
14
15 if (!success) {
16 return new NextResponse('Too many requests', { status: 429 })
17 }
18}

Security Headers thêm vào next.config.js:

JavaScript
1// next.config.js
2const securityHeaders = [
3 { key: 'X-Frame-Options', value: 'DENY' },
4 { key: 'X-Content-Type-Options', value: 'nosniff' },
5 {
6 key: 'Content-Security-Policy',
7 value: `default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline';`
8 },
9]
10
11module.exports = {
12 async headers() {
13 return [{
14 source: '/(.*)',
15 headers: securityHeaders,
16 }]
17 },
18}

Anh em lưu ý: secure: true trên cookie chỉ work khi chạy HTTPS. Local dev thường là HTTP nên set secure: process.env.NODE_ENV === 'production' đừng hardcode true rồi wonder tại sao cookie không được set ở local.

Cuối cùng, một điều mình hay nhắc junior trong team: đừng lưu sensitive data trong JWT payload. JWT chỉ được encode, không encrypt ai cũng decode được nếu có token. Lưu userIdrole là đủ, không cần lưu email, tên, hay bất kỳ PII nào khác trong đó.

NN

Nguyễn Nhật Long

@nguyennhatlong1303

Nguyễn Nhật Long is a Senior Frontend Engineer and Frontend Team Leader with 7 years of experience building real-time fintech platforms. Specializing in React, Next.js, TypeScript, and React Native, shipping 10+ products across Web, Mobile, Telegram Mini-Apps, and Web3.

Thấy hay? Chia sẻ cho bạn bè!

Auth trong Next.js: Middleware, Cookie và mấy thứ không ai nói cho bạn — Stacklog