nextjs.vnEnglish

Tất cả lỗ hổng

Xác thực trong Next.js (App Router): phiên, phân quyền, Proxy

Xác thực gồm ba việc: biết người dùng là ai (đăng nhập), giữ trạng thái đó qua các request (phiên), và quyết định họ được xem, được làm gì (phân quyền). Trang này đi theo hướng dẫn xác thực trong tài liệu Next.js, sửa hai chỗ ví dụ gốc chạy không như mô tả, và chạy thử toàn bộ mã bên dưới trên một ứng dụng thật.

Mã trên trang này là một ứng dụng chạy được: 16/16 kiểm tra đạt ngày 31/10/2026. Phiên bản đã chạy thử: next@16.4.3, react@19.3.1, jose@6.2.14, typescript@7.0.2, Node.js v26.0.0.

Tự viết hay dùng thư viện

Tài liệu Next.js khuyên dùng thư viện xác thực: chúng có sẵn phiên, phân quyền, đăng nhập mạng xã hội, xác thực nhiều lớp. Danh sách trong tài liệu gồm Auth0, Better Auth, Clerk, Descope, Kinde, Logto, NextAuth.js, Ory, Stack Auth, Supabase, Stytch và WorkOS; thư viện chỉ lo phiên là iron-session và jose.

Nếu bạn tìm “next auth”: trang chủ authjs.dev ghi dự án Auth.js nay thuộc Better Auth. Trên npm, bản ổn định mới nhất của gói next-auth vẫn là dòng 4 (4.24.15), dòng 5 còn ở nhãn beta (5.0.0-beta.32), theo dữ liệu ngày 31/10/2026.

Phần còn lại tự dựng mọi thứ, để thấy rõ từng lớp. Dù dùng thư viện nào, ba quy tắc ở cuối trang vẫn áp dụng.

1. Đăng nhập bằng Server Action

Form gọi một Server Action; hàm này chạy trên máy chủ, kiểm tra email và mật khẩu, tạo phiên rồi chuyển hướng. useActionState trả thông báo lỗi về form. Form vẫn chạy khi trình duyệt tắt JavaScript: bài kiểm tra bên dưới đăng nhập đúng theo cách đó.

Sai mật khẩu và email không tồn tại nhận cùng một câu trả lời, để không ai dò được email nào đã đăng ký. Mật khẩu chỉ lưu dạng băm (scrypt với salt riêng) và so bằng timingSafeEqual.

Next.js tự chặn Server Action gửi từ trang khác: khi header Origin không khớp với host, Next.js 16.4.3 huỷ action (đã thử: mật khẩu đúng gửi từ origin khác không tạo phiên).

app/actions/auth.ts
'use server'
import { redirect } from 'next/navigation'
import { createSession, deleteSession } from '@/app/lib/session'
import { checkCredentials } from '@/app/lib/users'

export type LoginState = { error?: string } | undefined

export async function login(_state: LoginState, formData: FormData): Promise<LoginState> {
  const email = String(formData.get('email') ?? '')
  const password = String(formData.get('password') ?? '')
  const user = checkCredentials(email, password)
  // Một thông báo chung: không cho biết email có tồn tại hay không.
  if (!user) return { error: 'Email hoặc mật khẩu không đúng.' }
  await createSession(user.id, user.role)
  redirect('/dashboard')
}

export async function logout() {
  await deleteSession()
  redirect('/login')
}
app/login/page.tsx
'use client'
import { useActionState } from 'react'
import { login } from '@/app/actions/auth'

export default function LoginPage() {
  const [state, action, pending] = useActionState(login, undefined)
  return (
    <form action={action}>
      <input name="email" type="email" required />
      <input name="password" type="password" required />
      {state?.error && <p role="alert">{state.error}</p>}
      <button disabled={pending}>Đăng nhập</button>
    </form>
  )
}
app/lib/users.ts
import 'server-only'
import { scryptSync, timingSafeEqual } from 'node:crypto'
import type { Role } from '@/app/lib/session'

// Thay bằng bảng người dùng trong cơ sở dữ liệu. Mật khẩu chỉ lưu dạng băm (scrypt + salt riêng).
const USERS = [
  { id: '1', email: 'admin@example.com', role: 'admin' as Role, salt: 'a1', hash: scryptSync('mat-khau-thu', 'a1', 32).toString('hex') },
  { id: '2', email: 'user@example.com', role: 'user' as Role, salt: 'b2', hash: scryptSync('mat-khau-thu', 'b2', 32).toString('hex') },
]

export function checkCredentials(email: string, password: string) {
  const user = USERS.find((u) => u.email === email)
  if (!user) return null
  const ok = timingSafeEqual(scryptSync(password, user.salt, 32), Buffer.from(user.hash, 'hex'))
  return ok ? user : null
}

2. Phiên không trạng thái: JWT trong cookie

Phiên được ký bằng khoá bí mật và cất trong cookie. Tạo khoá bằng openssl rand -base64 32, đặt vào biến môi trường SESSION_SECRET, không bao giờ đưa vào mã. Dòng import 'server-only' làm build lỗi nếu file này lọt vào mã phía trình duyệt.

Payload chỉ chứa thứ cần cho các request sau (ID, vai trò), không chứa email, số điện thoại hay mật khẩu. Cookie đặt trên máy chủ với httpOnly, secure, sameSite: 'lax', path và hạn dùng.

jwtVerify được ghim algorithms: ['HS256']: token tự xưng alg: none hay ký bằng khoá khác đều bị từ chối (đã thử cả hai).

Muốn thu hồi phiên ngay (đăng xuất mọi thiết bị, khoá tài khoản), hãy dùng phiên lưu trong cơ sở dữ liệu: trình duyệt chỉ giữ ID phiên, máy chủ tra lại mỗi lần. Cách này an toàn hơn nhưng tốn tài nguyên hơn.

app/lib/session.ts
import 'server-only'
import { SignJWT, jwtVerify } from 'jose'
import { cookies } from 'next/headers'

export type Role = 'admin' | 'user'
export type SessionPayload = { userId: string; role: Role; expiresAt: Date }

const encodedKey = new TextEncoder().encode(process.env.SESSION_SECRET)

export async function encrypt(payload: SessionPayload) {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('7d')
    .sign(encodedKey)
}

export async function decrypt(session: string | undefined = '') {
  try {
    const { payload } = await jwtVerify<SessionPayload>(session, encodedKey, { algorithms: ['HS256'] })
    return payload
  } catch {
    return null
  }
}

export async function createSession(userId: string, role: Role) {
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
  const session = await encrypt({ userId, role, expiresAt })
  const cookieStore = await cookies()
  cookieStore.set('session', session, {
    httpOnly: true,
    secure: true,
    expires: expiresAt,
    sameSite: 'lax',
    path: '/',
  })
}

export async function deleteSession() {
  const cookieStore = await cookies()
  cookieStore.delete('session')
}

3. Phân quyền ở Data Access Layer (DAL)

Mọi lần đọc dữ liệu cần quyền đều đi qua một lớp duy nhất kiểm tra phiên. cache của React gộp các lần gọi trong một lượt render; trong Server Action và Route Handler không có lượt render nên mỗi lần gọi đều chạy lại.

Ở đây mã khác ví dụ trong tài liệu hai chỗ. Một: verifySession() của tài liệu chỉ trả isAuth và userId, nhưng các ví dụ sau lại đọc session.role — giá trị này luôn undefined, nên kiểm tra quyền admin luôn trượt. Ở đây vai trò nằm trong payload và được trả ra.

Hai: verifySession() gọi redirect() khi không có phiên, nên dòng if (!session) return 401 trong ví dụ Route Handler không bao giờ chạy. Đã đo: request không phiên nhận 307 về /login, không phải 401. Vì vậy có thêm getSession() trả null cho API, còn verifySession() dành cho trang.

app/lib/dal.ts
import 'server-only'
import { cache } from 'react'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { decrypt } from '@/app/lib/session'

// Đọc phiên, trả null khi không có: dùng cho Route Handler (trả 401) và Server Action.
export const getSession = cache(async () => {
  const session = await decrypt((await cookies()).get('session')?.value)
  return session?.userId ? { userId: session.userId, role: session.role } : null
})

// Dùng cho trang: không có phiên thì chuyển về /login.
export const verifySession = cache(async () => {
  const session = await getSession()
  if (!session) redirect('/login')
  return session
})
app/dashboard/page.tsx
import { verifySession } from '@/app/lib/dal'

export default async function DashboardPage() {
  const session = await verifySession()
  return <h1>Xin chào, người dùng {session.userId} ({session.role})</h1>
}

4. Route Handler và Server Action là endpoint công khai

Ai cũng gọi được chúng trực tiếp, không cần qua giao diện. Kiểm tra quyền bên trong từng hàm: không có phiên trả 401, có phiên nhưng thiếu quyền trả 403. Ẩn nút trên giao diện không phải là phân quyền.

app/api/admin/route.ts
import { getSession } from '@/app/lib/dal'

export async function GET() {
  const session = await getSession()
  if (!session) return new Response(null, { status: 401 })
  if (session.role !== 'admin') return new Response(null, { status: 403 })
  return Response.json({ ok: true })
}

5. Proxy (Middleware cũ) chỉ để kiểm tra lạc quan

Từ Next.js 16.0.0, file middleware đổi tên thành proxy và mặc định chạy trên Node.js. Proxy chạy ở mọi route, kể cả khi prefetch, nên chỉ đọc cookie để chuyển hướng sớm, không truy vấn cơ sở dữ liệu.

Đừng để Proxy là lớp bảo vệ duy nhất. Năm 2025, CVE-2025-29927 cho phép đi vòng qua middleware: ứng dụng tự host chỉ kiểm tra quyền ở đó bị lộ trang lẽ ra phải chặn. Bước kiểm tra lặp lại ở DAL không nằm trong middleware, nên không bị đi vòng theo cách đó.

proxy.ts
import { NextRequest, NextResponse } from 'next/server'
import { decrypt } from '@/app/lib/session'

const protectedRoutes = ['/dashboard']
const publicRoutes = ['/login']

// Kiểm tra lạc quan: chỉ đọc cookie, không gọi cơ sở dữ liệu. Không thay cho kiểm tra trong DAL.
export default async function proxy(req: NextRequest) {
  const path = req.nextUrl.pathname
  const session = await decrypt(req.cookies.get('session')?.value)

  if (protectedRoutes.includes(path) && !session?.userId) {
    return NextResponse.redirect(new URL('/login', req.nextUrl))
  }
  if (publicRoutes.includes(path) && session?.userId) {
    return NextResponse.redirect(new URL('/dashboard', req.nextUrl))
  }
  return NextResponse.next()
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
}

Ba quy tắc, dùng thư viện nào cũng vậy

Kiểm tra quyền sát nơi đọc dữ liệu (DAL), không chỉ ở Proxy hay layout. Layout không render lại khi chuyển trang, và layout ẩn {children} không ngăn route con chạy.

Coi mỗi Server Action và Route Handler như một API công khai: tự kiểm tra phiên và quyền bên trong.

Chỉ trả về trình duyệt những trường cần hiển thị, không trả nguyên bản ghi người dùng.

Đã kiểm tra

  • proxy: /dashboard without a session redirects to /login: 307 /login
  • login, wrong password: no cookie, one generic message: no cookie + message
  • login, unknown email: same answer as a wrong password: no cookie + message
  • CSRF: the right password posted from another origin sets no cookie: no cookie
  • login, admin: Server Action answers with a redirect to /dashboard: 303 /dashboard
  • session cookie flags: expires httponly path samesite secure
  • session cookie SameSite value: lax
  • DAL: /dashboard with the admin session renders: 200 + greeting
  • proxy: /login with a session redirects to /dashboard: 307 /dashboard
  • route handler: no session: 401
  • route handler: user session: 403
  • route handler: admin session: 200
  • route handler: admin token signed with another key: 401
  • route handler: alg none token: 401
  • proxy: forged token is no session: 307
  • docs shape: verifySession() in a Route Handler with no session: 307 /login

Chạy lại kiểm tra: bun scripts/guides/verify-auth.ts

Liên quan

Nguồn

Tóm tắt và diễn giải bằng lời của chúng tôi; mã viết lại từ ví dụ trong tài liệu Next.js. Khi có khác biệt, tài liệu Next.js là chuẩn.