ข้ามไปยังเนื้อหา

Pattern การทำ Authentication

ทำ optimistic check เบา ๆ ใน proxy เพื่อ redirect request ที่ไม่ auth ชัด ๆ ออกไป แต่ทำ authorization จริงให้ใกล้ data ที่สุด ใน Data Access Layer ที่ verify session ก่อนจะ return อะไรออกมา

สองคำนี้มักถูกใช้ปนกัน แต่จริง ๆ มันตอบคำถามคนละคำถาม:

  • Authentication (authn): คนนี้คือใคร? verify ว่า user เป็นคนที่เขาอ้างจริงไหม — เช็ก password, session cookie ที่ valid, token ที่ verify แล้ว
  • Authorization (authz): เขาทำอะไรได้บ้าง? เมื่อรู้ identity ที่ authenticate แล้ว ตัดสินว่าเขาเข้าถึง route นี้, record นี้ หรือทำ mutation นี้ได้ไหม

request หนึ่งอาจ authenticated แล้ว (มี session valid) แต่ยัง unauthorized อยู่ก็ได้ (user คนนั้นแค่ไม่ใช่ admin) การแยกสองอย่างนี้ให้ชัดสำคัญตรงที่ว่า check ควรอยู่ตรงไหน: authentication มักเกิดครั้งเดียวต่อ request (resolve session) ส่วน authorization เกิดต่อ resource ตรงก่อนที่จะแตะ data ที่ resource นั้นป้องกันอยู่

auth ส่วนใหญ่วิ่งอยู่บน session ที่เก็บใน cookie แบบ HTTP-only ฝั่ง server เราอ่าน cookie นั้นด้วย cookies() จาก next/headers

helper ของ session ด้านล่างเซ็นและตรวจค่า cookie ผ่านไฟล์เล็ก ๆ ไฟล์เดียว:

app/lib/crypto.ts
import 'server-only'
import { SignJWT, jwtVerify } from 'jose'
const encodedKey = new TextEncoder().encode(process.env.SESSION_SECRET)
export type SessionPayload = { userId: string; expiresAt: Date }
export async function encrypt(payload: SessionPayload) {
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(encodedKey)
}
export async function decrypt(token: string): Promise<SessionPayload | null> {
try {
const { payload } = await jwtVerify(token, encodedKey, { algorithms: ['HS256'] })
return payload as SessionPayload
} catch {
return null
}
}
app/lib/session.ts
// read the session cookie on the server
import { cookies } from 'next/headers'
import { decrypt } from '@/app/lib/crypto'
export async function getSession() {
const cookie = (await cookies()).get('session')?.value
if (!cookie) return null
return decrypt(cookie)
}

การ set session cookie ไม่ใช่แค่ cookies().set('session', value) — option แต่ละตัวในตารางข้างล่างปิดช่องโหว่จริง ๆ คนละแบบ cookies().set(name, value, options) เรียกได้เฉพาะใน Server Function หรือ Route Handler เท่านั้น (HTTP ห้าม set cookie หลัง streaming เริ่มไปแล้ว)

OptionTypeป้องกันอะไร
httpOnlybooleanบล็อกไม่ให้ client-side JavaScript (document.cookie) อ่าน cookie — กัน session ถูกขโมยผ่าน XSS payload
securebooleanส่ง cookie เฉพาะผ่าน HTTPS — กัน cookie รั่วบน connection HTTP ธรรมดา
sameSiteboolean | 'lax' | 'strict' | 'none'คุมว่า cookie ถูกส่งไปกับ cross-site request หรือเปล่า — เป็นด่านหลักกัน CSRF
pathstring, default '/'จำกัด cookie ให้อยู่แค่ path หนึ่งภายใน domain
expires / maxAgeDate / numberจำกัดว่า cookie ที่ถูกขโมยไปจะยัง valid ได้นานแค่ไหน
domainstringdomain ไหนที่ cookie ใช้ได้
partitionedbooleanเปิดใช้ CHIPS partitioned storage สำหรับ third-party context
// app/lib/session.ts — set a session cookie with the recommended attributes
import 'server-only'
import { cookies } from 'next/headers'
import { encrypt } from '@/app/lib/crypto'
export async function createSession(userId: string) {
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
const session = await encrypt({ userId, expiresAt })
const cookieStore = await cookies()
cookieStore.set('session', session, {
httpOnly: true,
secure: true,
expires: expiresAt,
sameSite: 'lax',
path: '/',
})
}

session มีสองแบบ trade-off คือความง่ายกับการควบคุม:

Stateless sessionDatabase session
data อยู่ที่ไหนencrypt/sign ตรงใน cookie เลยเป็น row ใน database; cookie เก็บแค่ session ID
อ่าน sessiondecrypt cookie — ไม่มี I/Olookup session ID — query เพิ่มหนึ่งครั้ง (หรือมี cache คั่น)
revoke session ก่อนหมดอายุยาก — cookie ครบในตัวเองและ valid จนกว่าจะหมดอายุง่าย — ลบ row แล้ว session ตายทันที
track active session / “log out ทุกที่”ทำไม่ได้ถ้าไม่มี bookkeeping เพิ่มทำได้เป็นธรรมชาติ — query row ทั้งหมดของ user
เหมาะกับแอปง่าย ๆ overhead จัดการ session ต่ำแอปที่ต้องการ revoke, track device หรือ audit session
// app/lib/session.ts — stateless: the whole session lives in the signed cookie
import 'server-only'
import { SignJWT, jwtVerify } from 'jose'
const encodedKey = new TextEncoder().encode(process.env.SESSION_SECRET)
export async function encrypt(payload: { userId: string; expiresAt: Date }) {
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(encodedKey)
}
// app/lib/session.ts — database: the cookie only carries an opaque session id
import { cookies } from 'next/headers'
import { db } from '@/app/lib/db'
export async function createSession(userId: string) {
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
const [{ id }] = await db.insert(sessions).values({ userId, expiresAt }).returning({ id: sessions.id })
const cookieStore = await cookies()
cookieStore.set('session', id, { httpOnly: true, secure: true, expires: expiresAt, sameSite: 'lax', path: '/' })
}

proxy (proxy.ts) เป็นที่ที่เหมาะกับ gate แบบ optimistic ราคาถูก ถ้าไม่มี session ชัด ๆ ก็ redirect ก่อน render วิธีนี้เร็วก็จริง แต่แค่ redirect ห้ามใช้เป็นด่านเดียว เพราะ proxy รันทุก route รวมถึง prefetched route ด้วย ตรงนี้อ่าน session จาก cookie อย่างเดียวพอ — ห้าม query database ใน proxy เด็ดขาด

// proxy.ts — optimistic redirect only
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const protectedRoutes = ['/dashboard']
export function proxy(request: NextRequest) {
const path = request.nextUrl.pathname
const hasSession = request.cookies.has('session')
if (protectedRoutes.some((p) => path.startsWith(p)) && !hasSession) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}

proxy อย่างเดียวไม่พอ matcher ที่ exclude path ไหนก็จะข้าม Server Action call บน path นั้นไปด้วย layout ก็ไม่ได้ re-run ทุกครั้งที่ navigate และ check ที่อยู่แค่หน้า app อาจถูก bypass ได้ทั้งดุ้น CVE-2025-29927 เปิดให้ผู้โจมตีข้าม middleware ทั้งหมดด้วย header x-middleware-subrequest ที่ปลอมขึ้นมา (แก้แล้วใน Next.js 15.2.3) ทางแก้คือ Data Access Layer (DAL) จุดเดียวที่ verify session ก่อน return data ห่อด้วย React cache เพื่อให้รันครั้งเดียวต่อ request

app/lib/dal.ts
// verify the session close to the data
import 'server-only'
import { cache } from 'react'
import { redirect } from 'next/navigation'
import { getSession } from '@/app/lib/session'
export const verifySession = cache(async () => {
const session = await getSession()
if (!session?.userId) {
redirect('/login')
}
return { isAuth: true, userId: session.userId }
})

จากนั้นทุก page, Server Action หรือ route handler ที่แตะ data ที่ป้องกันไว้จะเรียก verifySession() ก่อน สำหรับโปรเจกต์จริง library อย่าง NextAuth (Auth.js), Clerk หรือ Better Auth จัดการ session, provider และ token ให้ แต่ pattern DAL สำหรับ authorization ก็ยังต้องวางทับ library พวกนี้อยู่ดี

นอกจาก redirect('/login') ธรรมดา Next.js มี function เฉพาะทางสองตัวสำหรับ authorization failure — และ ณ 16.3.6 ทั้งคู่ยัง experimental อยู่ ต้องเปิดผ่าน experimental.authInterrupts ใน next.config.ts:

next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
authInterrupts: true,
},
}
export default nextConfig
  • unauthorized() — throw, render unauthorized.tsx ที่ใกล้ที่สุด แล้ว return 401 จริง ใช้ตอน “คุณยังไม่ได้ sign in”
  • forbidden() — throw, render forbidden.tsx ที่ใกล้ที่สุด แล้ว return 403 จริง ใช้ตอน “sign in แล้ว แต่นี่ไม่ใช่ของคุณ”
app/admin/page.tsx
import { forbidden } from 'next/navigation'
async function verifySession() {
// Real apps call the DAL from the previous section; inlined here so the
// snippet has no cross-file dependency.
return { role: 'user' as string }
}
export default async function AdminPage() {
const session = await verifySession()
if (session.role !== 'admin') {
forbidden()
}
return <div>Admin Dashboard</div>
}
// app/admin/forbidden.tsx — rendered in place of the page when forbidden() throws
export default function Forbidden() {
return <h1>403 - Forbidden</h1>
}

พิสูจน์ทั้งสองด้านนี้ใน probe: เรียก forbidden() โดยปิด flag ไว้ทำให้ build พังตรง ๆ —

Error: `forbidden()` is experimental and only allowed to be enabled when `experimental.authInterrupts` is enabled.

— และเปิด flag แล้ว route เดียวกันก็ return status code จริงพร้อม UI ของเราเอง:

HTTP 403
<title>403: This page could not be accessed.</title>
<meta name="robots" content="noindex"/>
<h1>403 - Forbidden</h1>

ทั้งสอง function ทำงานด้วยการ throw (TypeScript return type เป็น never) นี่คือเหตุผลที่ try/catch ล้อมรอบมันคือ bug จริง ไม่ใช่แค่เรื่อง style: มันกลืน throw ไป แล้ว UI ของ forbidden/unauthorized ก็ไม่ render เลย ถ้า call นั้นอยู่หลัง promise ที่ไม่เคย await ก็เกิดผลแบบเดียวกัน — rejection ไม่มีอะไรมาจับ

นึกภาพ authentication เป็นด่านตรวจ passport ที่ชายแดน — มันถามแค่ “คนนี้มีตัวตนจริงไหม และเป็นคนที่เขาอ้างไหม” แล้วเกิดครั้งเดียว authorization คือยามรักษาความปลอดภัย ข้างใน ตึกที่เช็กทุกประตูว่าคนที่ verify แล้วคนนี้ผ่านประตู นี้ เฉพาะได้ไหม proxy คือคนต้อนรับหน้าประตูทางเข้าที่แค่เหลือบดูรูปใน passport ส่วน DAL คือยามที่ทุกประตูที่เช็ก badge กับ access list จริง ๆ ก่อนจะเปิดให้

สิ่งที่ได้ประโยชน์ต้นทุน
Stateless sessionอ่านได้โดยไม่ต้อง round-trip ไป databaserevoke ก่อนหมดอายุไม่ได้ถ้าไม่มี state เพิ่ม
Database sessionrevoke ได้ทันที, “log out ทุกที่” ได้ต้อง lookup เพิ่ม (หรือมี cache) ทุกครั้งที่อ่าน session
Optimistic check ใน proxyเร็ว รวมศูนย์ redirect กรณีชัด ๆ ได้ก่อนไม่ authoritative — bypass หรือถูก matcher/prefetch ข้ามได้
verifySession() ใน DALจุดเดียวที่ authoritative จริงต้องเรียกเองทุกที่ที่แตะ protected data
forbidden() / unauthorized()status code จริง + UI เฉพาะ throw ครั้งเดียวยัง experimental — ต้องมี authInterrupts และ try/catch หลงเหลือจะกลืนมันแบบเงียบ ๆ
  • authentication กับ authorization คือ check เดียวกัน — authentication ยืนยัน identity, authorization ตัดสินว่า identity นั้นทำอะไรได้บ้าง user ที่ login แล้วก็ยัง unauthorized สำหรับ resource บางอันได้
  • stateless (แบบ JWT) session “ลบ” ได้ง่าย ๆ — ทำไม่ได้ cookie ครบในตัวเองและ valid จนหมดอายุ เว้นแต่จะมี blocklist ฝั่ง server เพิ่ม (ซึ่งก็ดึง database round-trip ที่พยายามเลี่ยงกลับมาอยู่ดี)
  • forbidden() กับ unauthorized() เป็น API ที่ stable ใช้ทั่วไปได้แล้ว — ณ 16.3.6 ทั้งคู่ยัง experimental และต้องมี experimental.authInterrupts: true ไม่ใส่ flag จะทำให้ build พังพร้อม error ชัดเจน ไม่ใช่ fallback แบบเงียบ ๆ
  • ห่อ forbidden()/unauthorized() ด้วย try/catch คือโค้ด defensive เฉย ๆ — ทั้งคู่ทำงานด้วยการ throw try/catch ที่ล้อมมันจะกลืน interrupt แล้ว UI ของ forbidden/unauthorized ก็ไม่ render เลย
  • redirect ระดับ proxy คือ authorization — มันเป็นแค่ hint แบบ optimistic proxy ถูกข้ามได้ด้วยช่องว่างของ matcher, ไม่ re-run ทุกครั้งที่ client navigate แบบที่ layout อาจสมมติไว้ และ (ตาม CVE-2025-29927) เคย bypass ได้ทั้งดุ้นมาแล้วในอดีต
graph TD
  A["Request"] --> B["proxy: optimistic redirect (authn only)"]
  B --> C["Page / Server Action"]
  C --> D["verifySession() in DAL"]
  D -->|no session| E["redirect to /login"]
  D -->|valid, wrong role| F["forbidden()"]
  D -->|valid| G["Return protected data"]
gate แบบ optimistic ใน proxy, check ตัวจริงใน DAL

เมื่อ snippet ที่ generate มาจัดการ auth ให้เช็กสิ่งเหล่านี้ก่อนเชื่อมัน:

  • มันใช้ forbidden() / unauthorized() โดยไม่มี experimental.authInterrupts: true ใน next.config.ts หรือเปล่า? แบบนี้ทำให้ build พังพร้อม error ชัดเจนใน 16.3.6 — ไม่ใช่ no-op เงียบ ๆ
  • forbidden()/unauthorized()/redirect() ถูกห่อด้วย try/catch ที่ไหนหรือเปล่า? ทั้งสามตัวทำงานด้วยการ throw — try/catch ที่ล้อมจะกลืน interrupt แบบเงียบ ๆ
  • cookies().set('session', value) ข้าม options object ไปเลยหรือเปล่า? cookie ที่ไม่มี httpOnly/secure/sameSite ถูก script ที่แทรกเข้ามาอ่านได้และส่งข้าม site ได้
  • โค้ดถือว่า redirect ระดับ proxy คือ authorization check โดยไม่มี verifySession() เรียกใน DAL, page, Server Action หรือ Route Handler ที่แตะ data จริง ๆ หรือเปล่า?
  • มันสับสนระหว่าง “login แล้ว” กับ “อนุญาตแล้ว” หรือเปล่า? check แบบ if (session) return data ข้าม authorization ไปเลย — มันแค่ยืนยัน authentication เท่านั้น
  • สำหรับ stateless (JWT) session โค้ดสมมติว่ามี function “delete session” ฝั่ง server อยู่หรือเปล่า? revoke ทำไม่ได้ถ้าไม่มี database session หรือ blocklist

🐛 หา bug ในโค้ดนี้

app/admin/page.tsx
import { verifySession } from '@/app/lib/dal'
import { forbidden } from 'next/navigation'
export default async function AdminPage() {
const session = await verifySession()
try {
if (session.role !== 'admin') {
forbidden()
}
} catch {
// ignore and keep rendering
}
return <AdminDashboard />
}
เฉลย

forbidden() ทำงานด้วยการ throw — return type ของมันคือ never การห่อด้วย try/catch จับ throw นั้นแล้วกลืนไป ดังนั้น comment ใน catch block (“ignore and keep rendering”) คือสิ่งที่เกิดขึ้นจริง: user ที่ไม่ใช่ admin ก็ยังตกลงมาเห็น <AdminDashboard /> อยู่ดี ทางแก้คือห้ามห่อ forbidden()/unauthorized()/redirect() ด้วย try/catch เด็ดขาด — ถ้าตรงรอบ ๆ call นั้นจำเป็นต้องมี catch จริง ๆ ให้ใช้ unstable_rethrow ข้างใน catch เพื่อปล่อยให้ interrupt propagate ต่อไป

อ่าน session cookie ฝั่ง server ได้อย่างไร
ความต่างหลักระหว่าง authentication กับ authorization คืออะไร
route นี้จะเกิดอะไรขึ้นตอน build ด้วย Next.js 16.3.6?
// next.config.ts
const nextConfig = {}
// app/admin/page.tsx
import { forbidden } from 'next/navigation'
forbidden()
ทำไม database session ถึง revoke ง่ายกว่า stateless (แบบ JWT) session
โค้ดนี้ผิดตรงไหน?
export async function AdminPage() {
  const session = await verifySession()
  try {
    if (session.role !== 'admin') forbidden()
  } catch {}
  return <Dashboard />
}
cookie option ตัวไหนคือด่านหลักในการกัน CSRF