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

Security และ Patterns

ทุก Server Action คือ public HTTP endpoint เราจึงต้อง validate input และ authorize ผู้เรียกภายใน action เอง อย่าเชื่อว่าถูกเรียกจาก UI ของเราเท่านั้น

ใครที่รู้ action ID ก็เรียก action ด้วย payload อะไรก็ได้ ดังนั้น action ต้องเช็คซ้ำว่าผู้ใช้คือใคร และ parse input ใหม่ทุกครั้งที่ถูกเรียก authorize ก่อน แล้ว validate แล้วค่อย mutate UI ที่ render form ไม่ใช่ security boundary

// app/actions.ts — authorize, then validate, then mutate
'use server'
import { auth } from '@/lib/auth'
import { z } from 'zod'
const schema = z.object({ id: z.string().uuid(), title: z.string().min(1) })
export async function updatePost(formData: FormData) {
const session = await auth()
if (!session?.user) throw new Error('Unauthorized')
const parsed = schema.safeParse({
id: formData.get('id'),
title: formData.get('title'),
})
if (!parsed.success) return { error: 'Invalid input' }
await db.post.update({
where: { id: parsed.data.id, authorId: session.user.id },
data: { title: parsed.data.title },
})
}

Next.js เข้ารหัสตัวแปรที่เรา close over ใน action สร้าง action ID ที่เดาไม่ได้ และ dead-code-eliminate action ที่เราไม่เคยใช้ ทำให้ action พวกนั้นไม่กลายเป็น public endpoint สิ่งเหล่านี้ลด attack surface แต่ไม่มีตัวไหนเช็คว่าผู้ใช้คนนี้ทำ action นี้ได้ไหม authorization และ validation จึงยังเป็นหน้าที่ของเรา

// app/actions.ts — unused actions are stripped at build; used ones get an ID
'use server'
// Used in the app: Next.js creates an encrypted, unguessable ID for it.
export async function updateUser(formData: FormData) {}
// Never imported anywhere: dead-code-eliminated, no public endpoint exists.
export async function deleteUser(formData: FormData) {}

เพื่อเลี่ยงการเขียน auth และ validation ซ้ำ ให้ห่อ action ด้วย helper ที่ authorize, parse ด้วย schema และ return ผลลัพธ์แบบ typed ทุก action จะ return รูปแบบ success หรือ error เดียวกัน ซึ่งเข้ากับ useActionState ได้อย่างสะอาด

// app/lib/action.ts — one wrapper: authorize, validate, typed result
import { auth } from '@/lib/auth'
import { z } from 'zod'
type Result<T> = { ok: true; data: T } | { ok: false; error: string }
export function authedAction<S extends z.ZodType, T>(
schema: S,
run: (input: z.infer<S>, userId: string) => Promise<T>,
) {
return async (formData: FormData): Promise<Result<T>> => {
const session = await auth()
if (!session?.user) return { ok: false, error: 'Unauthorized' }
const parsed = schema.safeParse(Object.fromEntries(formData))
if (!parsed.success) return { ok: false, error: 'Invalid input' }
const data = await run(parsed.data, session.user.id)
return { ok: true, data }
}
}

อย่ากระจาย query ดิบไปทั่ว action ให้ทุก read และ write ผ่าน data-access layer ที่แต่ละ function บังคับ ownership check ของตัวเอง action เรียก layer นั้น authorization จึงอยู่ติดกับข้อมูลและลืมยาก

// app/data/posts.ts — the only place that touches the posts table
import 'server-only'
export async function updatePostForUser(id: string, userId: string, title: string) {
return db.post.update({
where: { id, authorId: userId },
data: { title },
})
}
graph TD
  A["Public request hits action ID"] --> B{"Authorized user?"}
  B -->|"No"| C["Reject: Unauthorized"]
  B -->|"Yes"| D{"Input valid by schema?"}
  D -->|"No"| E["Reject: Invalid input"]
  D -->|"Yes"| F["Data-access layer with ownership check"]
  F --> G["Mutate the database"]
Every request re-checks auth and input
Why must you authorize and validate inside every Server Action?
What do the built-in protections like unguessable action IDs provide?
What happens to a Server Action that is never used anywhere?
What is the point of a data-access layer for mutations?