Authentication in Next.js (App Router): sessions, authorization, Proxy
Auth is three jobs: knowing who the user is (login), keeping that across requests (session), and deciding what they may see and do (authorization). This page follows the authentication guide in the Next.js docs, fixes two places where the original examples do not behave as described, and runs all the code below in a real app.
The code on this page is an app that runs: 16/16 checks passed on 2026-10-31. Versions run: next@16.4.3, react@19.3.1, jose@6.2.14, typescript@7.0.2, Node.js v26.0.0.
Build it or use a library
The Next.js docs recommend an auth library: they ship sessions, authorization, social login and multi-factor auth. The docs list Auth0, Better Auth, Clerk, Descope, Kinde, Logto, NextAuth.js, Ory, Stack Auth, Supabase, Stytch and WorkOS; for sessions alone, iron-session and jose.
If you searched for “next auth”: the authjs.dev home page says the Auth.js project is now part of Better Auth. On npm the latest stable next-auth is still the 4.x line (4.24.15); 5.x is still tagged beta (5.0.0-beta.32), as of 31 Oct 2026.
The rest builds every layer by hand so each one is visible. Whatever library you pick, the three rules at the end still apply.
1. Login with a Server Action
The form calls a Server Action, which runs on the server, checks the email and password, creates the session and redirects. useActionState brings the error message back to the form. The form also works with JavaScript off: the check below logs in exactly that way.
A wrong password and an unknown email get the same answer, so nobody can probe which emails are registered. Passwords are stored only as hashes (scrypt with a per-user salt) and compared with timingSafeEqual.
Next.js blocks Server Actions posted from another site: when the Origin header does not match the host, Next.js 16.4.3 aborts the action (tested: the right password posted from another origin creates no session).
'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')
}'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>
)
}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. Stateless session: a JWT in a cookie
The session is signed with a secret key and stored in a cookie. Generate the key with openssl rand -base64 32, put it in the SESSION_SECRET environment variable, never in code. import 'server-only' fails the build if this file ever ends up in browser code.
The payload holds only what later requests need (ID, role) — no email, phone number or password. The cookie is set on the server with httpOnly, secure, sameSite: 'lax', path and an expiry.
jwtVerify is pinned to algorithms: ['HS256']: a token claiming alg: none or signed with another key is rejected (both tested).
If you need to revoke a session at once (sign out everywhere, lock an account), use database sessions: the browser holds only a session ID and the server looks it up every time. Safer, but it costs more resources.
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. Authorization in a Data Access Layer (DAL)
Every read that needs permission goes through one layer that checks the session. React's cache dedupes calls within one render pass; Server Actions and Route Handlers have no render pass, so every call runs again.
This code differs from the docs example in two places. One: the docs' verifySession() returns only isAuth and userId, yet later examples read session.role — always undefined, so the admin check always fails. Here the role is in the payload and returned.
Two: verifySession() calls redirect() when there is no session, so the example Route Handler's if (!session) return 401 never runs. Measured: a request with no session gets 307 to /login, not 401. Hence a separate getSession() that returns null for APIs, and verifySession() for pages.
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
})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 Handlers and Server Actions are public endpoints
Anyone can call them directly, without your UI. Check inside each one: no session is 401, a session without the right role is 403. Hiding a button is not authorization.
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 (formerly Middleware) is for optimistic checks only
Since Next.js 16.0.0 the middleware file is renamed proxy and defaults to the Node.js runtime. Proxy runs on every route, prefetches included, so it should only read the cookie to redirect early — no database queries.
Never make Proxy the only line of defence. In 2025, CVE-2025-29927 let requests skip middleware: self-hosted apps that checked access only there exposed pages they meant to block. A check repeated in the DAL is not part of middleware, so it is not skipped that way.
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$).*)'],
}Three rules, whatever library you use
Check access next to the data (the DAL), not only in Proxy or a layout. Layouts do not re-render on navigation, and a layout that hides {children} does not stop the child route from running.
Treat every Server Action and Route Handler as a public API: check the session and role inside it.
Send the browser only the fields it shows, never the whole user record.
Checked
- 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
Re-run the checks: bun scripts/guides/verify-auth.ts
Related
Sources
- Next.js docs — How to implement authentication in Next.js
- Next.js docs — proxy.js (Middleware renamed to Proxy in v16.0.0)
- jose (panva/jose)
- Auth.js — “The Auth.js project is now part of Better Auth”
- npm registry — next-auth dist-tags
Our summary and wording; the code is rewritten from the examples in the Next.js docs. Where they differ, the Next.js docs win.