Next.js Server Action Validation
Concept of the skill
What it is: The validation and authorization discipline for Next.js Server Actions that accept client-originated input. Mental model: A Server Action is a public POST endpoint wearing function-call syntax. Why it exists: Convenience syntax can hide the HTTP boundary, leading developers to skip auth, input validation, or org scoping. What it is NOT: It is not GET route handling, Server Component data fetching, or Stripe webhook verification. Adjacent concepts: Zod schemas, auth checks, org-scoped mutations, controlled error surfaces. One-line analogy: It is the security checkpoint every form submission crosses before touching the database. Common misconception: Only the project UI can call the action; any HTTP client can call the generated endpoint.
Coverage
- The public endpoint reality —
'use server'functions are exposed as POST endpoints that any HTTP client can call directly; the Next.js call graph is not a security boundary - Validation order — auth check BEFORE Zod parse, Zod parse BEFORE database access; any other order produces an exploitable window
- Zod schema design for actions — using
.safeParse()over.parse()to return structured errors rather than throwing; returning{ error: ... }shapes that the calling Client Component can render - Org-scoping after authentication — verifying that
input.orgIdmatches the session's org, not just that the user is logged in - Error surface control — how
try/catcharound database calls prevents internal errors from propagating to the client response
Philosophy of the skill
Server Actions are a convenience feature that makes form submission feel like a function call. That convenience obscures a critical fact: the action is a public HTTP POST endpoint. A developer who writes const { data } = await myAction(formData) in a Client Component is writing what looks like a local function call, but the runtime sends an HTTP request that any script can replicate. Skipping auth or validation because "it's called from our own UI" is the same reasoning that made getServerSideProps data-fetching functions leaky in the Pages Router — the server boundary does not restrict callers.
Standard Action Pattern
"use server";
import { z } from "zod";
import { getServerSession } from "@/lib/auth";
import { orgQuery } from "@/lib/db";
const CreateOrderSchema = z.object({
orgId: z.string().uuid(),
planId: z.string().min(1),
quantity: z.number().int().positive(),
});
export async function createOrder(input: unknown) {
// 1. Auth check — before anything else
const session = await getServerSession();
if (!session?.user) {
return { error: "Unauthorized" };
}
// 2. Zod parse — structured errors, not thrown exceptions
const parsed = CreateOrderSchema.safeParse(input);
if (!parsed.success) {
return { error: parsed.error.flatten() };
}
// 3. Org-scope check — user must belong to the org they are acting on
if (session.user.orgId !== parsed.data.orgId) {
return { error: "Forbidden" };
}
// 4. Database write — inside orgQuery for RLS enforcement
try {
const [order] = await orgQuery(parsed.data.orgId, (tx) =>
tx`INSERT INTO orders (org_id, plan_id, quantity) VALUES (
${parsed.data.orgId}, ${parsed.data.planId}, ${parsed.data.quantity}
) RETURNING *`
);
return { data: order };
} catch {
return { error: "Failed to create order" };
}
}
Validation Order Rationale
| Order | Risk |
|---|---|
| Auth → Zod → orgScope → DB | Correct — each gate eliminates the next attack surface |
| Zod → Auth → DB | Attacker can probe the schema structure without authenticating |
| Auth → DB (no Zod) | Malformed input reaches the query layer; injection risk |
| DB → Auth (anywhere) | Unauthenticated database reads before rejection |
Verification
- Every
'use server'function callsgetServerSession()as its first statement - Every
'use server'function runsSchema.safeParse()before any database call - The session org ID is compared to the input org ID before the database call
- Database calls are wrapped in
orgQuery, not bare SQL - Errors returned to the client do not include stack traces or query text
Do NOT Use When
| Use instead | When |
|---|---|
stripe-webhook-signature-verification |
The entry point is a webhook route handler, not a Server Action |
| (a data-fetching skill) | The function is a Server Component that fetches data, with no user-submitted input |
postgres-rls-pattern |
The task is defining the database-layer policy, not the action-layer validation |