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.
Nguyễn Nhật Long
@nguyennhatlong1303
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ó:
1// middleware.ts2import { NextResponse } from 'next/server'3import type { NextRequest } from 'next/server'45export function middleware(request: NextRequest) {6 const { pathname } = request.nextUrl78 const protectedPaths = ['/dashboard', '/admin', '/settings']9 const isProtected = protectedPaths.some(p => pathname.startsWith(p))1011 if (isProtected) {12 const token = request.cookies.get('session')13 if (!token) {14 return NextResponse.redirect(new URL('/login', request.url))15 }16 }1718 return NextResponse.next()19}2021export 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:
1// app/actions/auth.ts2'use server'34import { cookies } from 'next/headers'5import { redirect } from 'next/navigation'6import { SignJWT } from 'jose'78export async function login(formData: FormData) {9 const email = formData.get('email') as string10 const password = formData.get('password') as string1112 // Verify credentials với database13 const user = await verifyCredentials(email, password)14 if (!user) {15 return { error: 'Invalid credentials' }16 }1718 // Tạo JWT19 const token = await new SignJWT({20 userId: user.id,21 role: user.role22 })23 .setProtectedHeader({ alg: 'HS256' })24 .setExpirationTime('7d')25 .sign(new TextEncoder().encode(process.env.JWT_SECRET))2627 // Set httpOnly cookie28 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ày33 path: '/'34 })3536 redirect('/dashboard')37}3839export async function logout() {40 cookies().delete('session')41 redirect('/login')42}
Mình dùng jose library thay vì jsonwebtoken vì jose 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:
1// middleware.ts version đầy đủ hơn2import { jwtVerify } from 'jose'34async function verifyToken(token: string) {5 try {6 const { payload } = await jwtVerify(7 token,8 new TextEncoder().encode(process.env.JWT_SECRET)9 )10 return payload11 } catch {12 return null13 }14}1516export async function middleware(request: NextRequest) {17 const { pathname } = request.nextUrl18 const token = request.cookies.get('session')?.value1920 // Admin routes check thêm role21 if (pathname.startsWith('/admin')) {22 if (!token) return NextResponse.redirect(new URL('/login', request.url))2324 const payload = await verifyToken(token)25 if (!payload || payload.role !== 'admin') {26 return NextResponse.redirect(new URL('/403', request.url))27 }28 }2930 // Dashboard và các protected routes thông thường31 if (pathname.startsWith('/dashboard')) {32 if (!token) return NextResponse.redirect(new URL('/login', request.url))3334 const payload = await verifyToken(token)35 if (!payload) {36 // Token expired hoặc invalid clear cookie và redirect37 const response = NextResponse.redirect(new URL('/login', request.url))38 response.cookies.delete('session')39 return response40 }41 }4243 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:
1// Trong JWT payload2{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:
1// app/dashboard/layout.tsx2import { cookies } from 'next/headers'3import { jwtVerify } from 'jose'45async function getSession() {6 const token = cookies().get('session')?.value7 if (!token) return null89 try {10 const { payload } = await jwtVerify(11 token,12 new TextEncoder().encode(process.env.JWT_SECRET)13 )14 return payload15 } catch {16 return null17 }18}1920export default async function DashboardLayout({ children }) {21 const session = await getSession()2223 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.
| Library | Type | OAuth | Database | Độ phức tạp | Phù hợp khi |
|---|---|---|---|---|---|
| **NextAuth.js (Auth.js)** | Self-hosted | ✅ 50+ providers | Adapter-based | Trung bình | Cần OAuth nhanh, project vừa |
| **Lucia** | Self-hosted | Manual | Database-agnostic | Cao hơn | Muốn control hoàn toàn |
| **Clerk** | Managed SaaS | ✅ Built-in | Managed | Thấp | Startup, MVP, không muốn lo auth |
| **Supabase Auth** | Managed SaaS | ✅ Built-in | Supabase only | Thấp | Đã dùng Supabase |
| **Custom JWT** | Self-hosted | Manual | Tự chọn | Cao nhất | Enterprise, 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ế:
1// middleware.ts thêm rate limiting2import { Ratelimit } from '@upstash/ratelimit'3import { Redis } from '@upstash/redis'45const ratelimit = new Ratelimit({6 redis: Redis.fromEnv(),7 limiter: Ratelimit.slidingWindow(5, '1 m'), // 5 requests/phút8})910// Trong middleware, apply cho /api/login11if (pathname === '/api/login') {12 const ip = request.ip ?? '127.0.0.1'13 const { success } = await ratelimit.limit(ip)1415 if (!success) {16 return new NextResponse('Too many requests', { status: 429 })17 }18}
Security Headers thêm vào next.config.js:
1// next.config.js2const 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]1011module.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 userId và role là đủ, không cần lưu email, tên, hay bất kỳ PII nào khác trong đó.
Nguyễn Nhật Long
@nguyennhatlong1303Nguyễ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è!
