Pattern การทำ Authentication
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”ทำ optimistic check เบา ๆ ใน proxy เพื่อ redirect request ที่ไม่ auth ชัด ๆ ออกไป แต่ทำ authorization จริงให้ใกล้ data ที่สุด ใน Data Access Layer ที่ verify session ก่อนจะ return อะไรออกมา
Authentication vs Authorization
หัวข้อที่มีชื่อว่า “Authentication vs Authorization”สองคำนี้มักถูกใช้ปนกัน แต่จริง ๆ มันตอบคำถามคนละคำถาม:
- 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 นั้นป้องกันอยู่
Session และ cookie
หัวข้อที่มีชื่อว่า “Session และ cookie”auth ส่วนใหญ่วิ่งอยู่บน session ที่เก็บใน cookie แบบ HTTP-only ฝั่ง server เราอ่าน cookie นั้นด้วย cookies() จาก next/headers
helper ของ session ด้านล่างเซ็นและตรวจค่า cookie ผ่านไฟล์เล็ก ๆ ไฟล์เดียว:
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 }}// read the session cookie on the serverimport { 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)}Cookie attribute และเหตุผลที่แต่ละตัวสำคัญ
หัวข้อที่มีชื่อว่า “Cookie attribute และเหตุผลที่แต่ละตัวสำคัญ”การ set session cookie ไม่ใช่แค่ cookies().set('session', value) — option แต่ละตัวในตารางข้างล่างปิดช่องโหว่จริง ๆ คนละแบบ cookies().set(name, value, options) เรียกได้เฉพาะใน Server Function หรือ Route Handler เท่านั้น (HTTP ห้าม set cookie หลัง streaming เริ่มไปแล้ว)
| Option | Type | ป้องกันอะไร |
|---|---|---|
httpOnly | boolean | บล็อกไม่ให้ client-side JavaScript (document.cookie) อ่าน cookie — กัน session ถูกขโมยผ่าน XSS payload |
secure | boolean | ส่ง cookie เฉพาะผ่าน HTTPS — กัน cookie รั่วบน connection HTTP ธรรมดา |
sameSite | boolean | 'lax' | 'strict' | 'none' | คุมว่า cookie ถูกส่งไปกับ cross-site request หรือเปล่า — เป็นด่านหลักกัน CSRF |
path | string, default '/' | จำกัด cookie ให้อยู่แค่ path หนึ่งภายใน domain |
expires / maxAge | Date / number | จำกัดว่า cookie ที่ถูกขโมยไปจะยัง valid ได้นานแค่ไหน |
domain | string | domain ไหนที่ cookie ใช้ได้ |
partitioned | boolean | เปิดใช้ CHIPS partitioned storage สำหรับ third-party context |
// app/lib/session.ts — set a session cookie with the recommended attributesimport '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: '/', })}Stateless vs Database session
หัวข้อที่มีชื่อว่า “Stateless vs Database session”session มีสองแบบ trade-off คือความง่ายกับการควบคุม:
| Stateless session | Database session | |
|---|---|---|
| data อยู่ที่ไหน | encrypt/sign ตรงใน cookie เลย | เป็น row ใน database; cookie เก็บแค่ session ID |
| อ่าน session | decrypt cookie — ไม่มี I/O | lookup 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 cookieimport '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 idimport { 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: '/' })}Optimistic check ใน proxy
หัวข้อที่มีชื่อว่า “Optimistic check ใน proxy”proxy (proxy.ts) เป็นที่ที่เหมาะกับ gate แบบ optimistic ราคาถูก ถ้าไม่มี session ชัด ๆ ก็ redirect ก่อน render วิธีนี้เร็วก็จริง แต่แค่ redirect ห้ามใช้เป็นด่านเดียว เพราะ proxy รันทุก route รวมถึง prefetched route ด้วย ตรงนี้อ่าน session จาก cookie อย่างเดียวพอ — ห้าม query database ใน proxy เด็ดขาด
// proxy.ts — optimistic redirect onlyimport { 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()}Authorization ตัวจริงอยู่ใน Data Access Layer
หัวข้อที่มีชื่อว่า “Authorization ตัวจริงอยู่ใน Data Access Layer”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
// verify the session close to the dataimport '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 พวกนี้อยู่ดี
forbidden() และ unauthorized()
หัวข้อที่มีชื่อว่า “forbidden() และ unauthorized()”นอกจาก redirect('/login') ธรรมดา Next.js มี function เฉพาะทางสองตัวสำหรับ authorization failure — และ ณ 16.3.6 ทั้งคู่ยัง experimental อยู่ ต้องเปิดผ่าน experimental.authInterrupts ใน next.config.ts:
import type { NextConfig } from 'next'
const nextConfig: NextConfig = { experimental: { authInterrupts: true, },}
export default nextConfigunauthorized()— throw, renderunauthorized.tsxที่ใกล้ที่สุด แล้ว return401จริง ใช้ตอน “คุณยังไม่ได้ sign in”forbidden()— throw, renderforbidden.tsxที่ใกล้ที่สุด แล้ว return403จริง ใช้ตอน “sign in แล้ว แต่นี่ไม่ใช่ของคุณ”
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() throwsexport 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 ไม่มีอะไรมาจับ
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”นึกภาพ authentication เป็นด่านตรวจ passport ที่ชายแดน — มันถามแค่ “คนนี้มีตัวตนจริงไหม และเป็นคนที่เขาอ้างไหม” แล้วเกิดครั้งเดียว authorization คือยามรักษาความปลอดภัย ข้างใน ตึกที่เช็กทุกประตูว่าคนที่ verify แล้วคนนี้ผ่านประตู นี้ เฉพาะได้ไหม proxy คือคนต้อนรับหน้าประตูทางเข้าที่แค่เหลือบดูรูปใน passport ส่วน DAL คือยามที่ทุกประตูที่เช็ก badge กับ access list จริง ๆ ก่อนจะเปิดให้
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| สิ่งที่ได้ | ประโยชน์ | ต้นทุน |
|---|---|---|
| Stateless session | อ่านได้โดยไม่ต้อง round-trip ไป database | revoke ก่อนหมดอายุไม่ได้ถ้าไม่มี state เพิ่ม |
| Database session | revoke ได้ทันที, “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 เฉย ๆ — ทั้งคู่ทำงานด้วยการ throwtry/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"]
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อ 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 ในโค้ดนี้
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 ต่อไป