Validate Next.js Server Actions with Zod (and Authorize Them)

Server Actions are public POST endpoints. How to validate every input with Zod, check authentication and authorization inside each action, return safe errors to forms, and keep secrets and database logic server-only.

AAAayush AdhikariSeptember 24, 2026 6 min read

To validate Next.js Server Actions with Zod, treat every action as a public POST endpoint: parse its input with a Zod schema using safeParse before doing anything, verify the user's session inside the action, check that they're allowed to act on the specific record, and return a small result object with a friendly error — never a raw database row or stack trace. Keep database access and secrets in server-only modules. Next.js's guide is explicit: a page-level auth check doesn't protect the page's actions.

Server Actions are endpoints

It's easy to think of a Server Action as "a function my form calls". Next.js's documentation corrects that: when a Server Action is created and exported, it's reachable via a direct POST request, not just through your UI. Next.js adds protections — encrypted, non-deterministic action IDs; removal of unused actions; an Origin/Host check against cross-site request forgery — but it still says to treat actions as reachable by direct POST and to verify authentication and authorization inside each one.

So every action needs the same three things as an API route: validation, authentication and authorization.

Step 1: a schema at the boundary

Define the input shape once, with the constraints the business needs:

"use server";
import { z } from "zod";
 
const createTaskSchema = z.object({
  title: z.string().trim().min(3, { error: "Give it a title." }).max(160),
  details: z.string().trim().max(4000).optional(),
  dueAt: z.iso.datetime().nullable(),
  priority: z.enum(["low", "normal", "high", "urgent"]),
  assigneeIds: z.array(z.uuid()).max(10),
});

Rules that pay off:

  • Trim and bound every string. A title with no maximum length is a storage and rendering problem waiting to happen.
  • Enums for anything with a fixed set. A status or priority outside the list is rejected, not stored.
  • IDs are UUIDs, validated as such.
  • Arrays have maximum lengths.
  • Normalize empties. An empty string from a form often means "not set"; convert it to null with a preprocess step rather than storing "".

Parse with safeParse, which returns a result instead of throwing:

export async function createTask(input: unknown): Promise<Result> {
  const parsed = createTaskSchema.safeParse(input);
  if (!parsed.success) {
    return { ok: false, error: parsed.error.issues[0]?.message ?? "Please check the fields." };
  }
  // …
}

Type the parameter as unknown. The client's TypeScript types don't exist at runtime; anything can arrive.

Step 2: authenticate inside the action

const supabase = await createClient();          // cookie-based server client
const { data: { user } } = await supabase.auth.getUser();
if (!user) return { ok: false, error: "Please sign in again." };

Verify the session with a call that checks the token (getUser() or getClaims() with Supabase), not one that merely reads the cookie. The layout that rendered the form checked auth for rendering; the action is a separate entry point. Details in Next.js App Router + Supabase auth.

Step 3: authorize the specific action

Authentication says who the user is; authorization says whether they may do this to this record. Next.js's guide calls out insecure direct object references — acting on a record ID the user supplied without checking ownership. Options:

  • Check in code: load the record and compare its owner or organization to the user's.
  • Let the database check: write through the user's own session so row-level security rejects anything outside their permissions. This is what LetRelay does — actions use the user's client, so a forged task ID from another organization simply updates nothing. See row-level security vs app-layer authorization.

With database-enforced permissions, a missed check in code doesn't become a breach, which is exactly the property you want for endpoints anyone can POST to.

Step 4: return safe, useful results

Everything an action returns is serialized to the client. Return a small, stable shape:

type Result =
  | { ok: true; id?: string }
  | { ok: false; error: string; fieldErrors?: Record<string, string[] | undefined> };
  • No raw rows. Next.js warns that returning full database records can expose internal fields.
  • No internal error text. Log the database error on the server; return "Could not save the task." to the user.
  • Field errors for forms. Map Zod issues to fields so the form can show messages next to inputs.

On the client, useActionState gives you the returned state and a pending flag for disabling the submit button while the action runs.

Step 5: keep secrets server-only

  • Put database helpers and API calls in modules that start with import "server-only", so importing them into a Client Component fails the build.
  • Only variables prefixed with NEXT_PUBLIC_ reach the browser. Service-role keys and AI keys never get that prefix.
  • Closed-over values in inline actions are encrypted by Next.js, but its guide advises not relying on that alone for sensitive data. Pass IDs, not secrets.

Step 6: rate-limit expensive actions

Actions that send email, call an AI model or write a lot should be rate-limited per user — another point Next.js's guide makes. A per-user limiter in Postgres is enough for most apps; see rate limiting without Redis.

Step 7: one action, many callers

When the same operation can be triggered from a form, a keyboard shortcut and an AI assistant, route them all through one action. LetRelay's assistant executes confirmed actions by calling the screens' own Server Actions, so validation, permissions and side effects are identical however the action was triggered. See AI agent actions with human confirmation.

Testing actions like endpoints

Test Server Actions the way an attacker would call them — not only through the form:

  • Invalid shapes: missing fields, wrong types, oversized strings, unknown enum values. Each should return { ok: false } with a message, never throw an unhandled error.
  • Signed out: the action must refuse.
  • Another user's record: pass an ID from another user or organization. Nothing should change.
  • Another role: call an admin-only action as a regular user.

LetRelay's verification suite signs in as several roles across two organizations and calls actions directly with hostile inputs. It catches the class of bug code review misses: an action added without an authorization check, or a new field accepted without bounds.

An audit checklist

For every "use server" file:

  • Parameters typed unknown and parsed with a schema
  • Strings trimmed and bounded; enums for fixed sets; IDs validated
  • Session verified inside the action
  • Authorization checked for the specific record (in code or via RLS)
  • Returns a small result object; no raw rows or internal errors
  • Errors logged server-side
  • Secrets and database access in server-only modules
  • Expensive actions rate-limited

FAQ

Are Next.js Server Actions secure by default?

They include protections such as encrypted action IDs and an Origin/Host check, but they're still reachable by direct POST requests. You must validate input and check authentication and authorization inside each action.

Should I use parse or safeParse with Zod in Server Actions?

safeParse, so invalid input returns a friendly error result instead of throwing an exception.

Does a page-level auth check protect its Server Actions?

No. Next.js's documentation says the action is a separate entry point and must verify the caller on its own.

What should a Server Action return?

A small result object — success, an optional ID, or an error message and field errors — never raw database records or internal error details.

Sources

AA
Aayush Adhikari

Building Relay — the internal request desk with AI triage and SLA tracking.

Run your internal requests on LetRelay

AI triage, SLA-tracked queues, and bottleneck analytics — the help desk your team actually likes. Free to start.

Try LetRelay free No credit card required
Ad spaceYour Google AdSense unit shows here once approved.

Keep reading