- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Core Tech
Guided: Securing React 19 Server Actions
Server Actions are one of React 19's most powerful features, but every "use server" function is a public HTTP endpoint exposed to anyone who can read your JavaScript bundle. In this guided lab, you’ll secure a task management app built with Next.js 16 and React 19 by adding Zod input validation, server-side authentication gates, ownership-based authorization, and reusable action middleware. By the end, you’ll have a battle-tested security pattern you can apply to any Server Action in production.
Lab Info
Table of Contents
-
Challenge
## Introduction to Securing Server Actions
Welcome
Welcome to this guided lab on securing React 19 Server Actions! You'll work with a pre-built task management app powered by Next.js 16 that already has working Server Actions for
creating, editing, and deleting tasks. The problem? Those actions are wide open — they trust any input, skip authentication, and let anyone modify any task. Your job is to lock them down
using industry best practices: Zod validation, session-based authentication gates, ownership authorization, and reusable action middleware.Why Server Actions Need Security
Server Actions are asynchronous functions marked with the
"use server"directive. React 19 and Next.js compile each one into a dedicated HTTP POST endpoint. This is incredibly convenient — but it also means that anyone can discover these endpoints in your JavaScript bundle and call them directly with arbitrary payloads, completely bypassing your TypeScript types, your UI validation, and your component-level access controls. In production, an unsecured Server Action is as dangerous as an unprotected API route.What You'll Learn
In this lab, you will learn to treat every Server Action as a public API endpoint and defend it accordingly:
- Input validation with Zod — so that malformed or malicious data never reaches your business logic
- Authentication gates using a Data Access Layer — so that unauthenticated users cannot execute actions
- Authorization checks — so that users can only mutate their own resources
- Composable middleware — a clean, reusable pattern used in production SaaS applications
info> Should you get stuck at any time, a solutions folder contains the full code solution for every task in this lab, organized as solution/2.1-actions.ts, solution/2.2-actions.ts, and so on.
-
Challenge
## Validating Server Action Inputs with Zod
TypeScript provides excellent compile-time safety, but its types are completely erased at runtime. When a user submits a form — or when an attacker crafts a raw HTTP POST — your Server Action receives raw
FormDatawith no type guarantees whatsoever. This is why runtime validation is essential. Zod is a TypeScript-first schema validation library that lets you define the exact shape, type, and constraints of your data. If the input doesn't match, Zod returns structured error objects you can send directly back to the UI.In this step, you'll create Zod schemas and integrate them into your Server Actions so that invalid data is rejected before it ever reaches your database logic. ### Define a Zod Schema for Task Creation
Your first task is to create a shared validation schema. By placing the schema in a separate file (for example,
lib/schemas.ts), you can reuse it on both the server and client — ensuring a
single source of truth for your validation rules.You'll use Zod's
z.object()to define the shape of a new task:title— non-empty string, max 100 charactersdescription— optional string, max 500 characterspriority— enum of"low","medium","high"### Validate FormData Inside the createTask Server Action
Now you'll integrate your Zod schema into the actual Server Action. The key technique is
safeParse()— unlikeparse(), it never throws an exception. Instead it returns a
discriminated union:{ success: true, data }— validation passed,datais fully typed{ success: false, error }— validation failed,errorcontains structured field-level details
This lets you return structured validation errors to the client rather than crashing the server.
Inside your
createTaskaction, you'll extract fields fromFormDatausingformData.get(), pass them to your schema'ssafeParse(), and return field-level errors if validation fails. ### Add an Update Task Schema with ID ValidationUpdating a task requires an additional field: the task's ID. Zod makes it easy to extend or compose schemas — you'll create an
updateTaskSchemathat includes all the fields from
createTaskSchemaplus anidfield validated as a non-empty string.Zod's
.extend()method lets you build on existing schemas without duplication. -
Challenge
## Surfacing Validation Errors in the UI
Wire Validation Errors to the UI
Validation is only useful if users can see what went wrong. React 19 introduces the
useActionStatehook (imported from"react", not"react-dom"), which pairs perfectly with Server Actions. It gives you three things:- The current action state — including any errors your action returned
- A wrapped action function — to pass to your form's
actionprop - An
isPendingboolean — for loading states
In this step, you'll wire your validated Server Actions to the UI so that field-level errors appear inline beneath each input, and the submit button shows a pending state while the action executes. This creates a smooth, no-refresh experience with progressive enhancement built in. ### Wire
useActionStateto the Task Creation FormThe
useActionStatehook manages the round-trip between your form and the Server Action. You pass it your action function and an initial state, and it returns:state— the latest return value from your actionformAction— a wrapped function to pass to<form action={...}>isPending— a boolean for loading UI
The key insight: your Server Action's return value becomes the next state. So when you return
{ errors: { title: ["Required"] } }, that object flows directly into your component and you can render errors inline. ### Add a Pending State Indicator withuseFormStatusWhile a Server Action is executing, users need visual feedback. React 19 provides
useFormStatus(from"react-dom"), which must be called from a component rendered inside a<form>. It exposes apendingboolean that istruewhile the form's action is in flight.You'll create a
SubmitButtoncomponent that disables itself and shows a loading indicator during submission. -
Challenge
## Implementing Authentication Gates
Authentication
Validation ensures the data is well-formed, but it doesn't answer the critical question: who is making this request?
Every Server Action is a public HTTP POST endpoint — anyone with the URL can call it. If your action creates a task in the database without checking who's asking, unauthenticated users (or
attackers) can fill your database with garbage.The solution is to verify the user's session at the very beginning of every Server Action, before any business logic runs. In this step, you'll build a Data Access Layer (DAL) for session verification and gate your Server Actions behind it.
This follows the pattern recommended by the Next.js team after the CVE-2025-29927 middleware bypass vulnerability demonstrated that middleware alone is never sufficient for authentication. ### Build a Session Verification Utility (Data Access Layer)
A Data Access Layer centralizes your authentication logic in one place. You'll create a
verifySessionfunction inlib/dal.tsthat does the following:- Reads the session cookie.
- Validates it.
- Returns the authenticated user's data, or
nullif the session is invalid.
By wrapping this in React's
cache()function, you ensure the session is only verified once per request even if multiple Server Components or Actions call it. ### GatecreateTaskBehind AuthenticationNow you'll add an authentication check to the very beginning of your
createTaskServer Action. This is the critical security gate: ifverifySession()returnsnull, the action must immediately return an error and never proceed to validation or database logic.This ensures that even if an attacker calls the endpoint directly, they cannot create tasks without a valid session. ### Gate
updateTaskanddeleteTaskAuthentication must be applied consistently to every Server Action, not just the one you're focused on. An attacker will always target the action you forgot to protect.
-
Challenge
## Adding Authorization and Ownership Checks
Authorization
Authentication tells you who the user is. Authorization tells you what they're allowed to do.
Even after verifying a session, you must check that the authenticated user has permission to perform the specific operation they're requesting. In a task management app, this means ensuring users can only update or delete their own tasks.
Without this check, any authenticated user could modify anyone else's tasks by simply changing the task ID in the request. This is a classic Insecure Direct Object Reference (IDOR) vulnerability — one of the OWASP Top 10 — and Server Actions are just as susceptible to it as traditional API routes.
Add Ownership Verification to
updateTaskBefore updating a task, you need to fetch it from the database and compare its
ownerIdto the authenticated user'suserId. If they don't match, the action must reject the request.This prevents users from modifying tasks they don't own — even if they tamper with hidden form fields or craft raw HTTP requests. ### Add Ownership Verification to
deleteTaskApply the same ownership pattern to
deleteTask. Before deleting, verify the task exists and belongs to the requesting user. Same IDOR protection — now applied to a destructive
operation. ### Display Authorization Errors in the UIAuthorization failures need to be communicated clearly. Update your form components to handle the
messagefield in the action's return state.When an auth or authorization error occurs — no field-level errors, but a top-level
message— display it as a banner above the form. -
Challenge
## Conclusion and Next Steps
Congratulations
You've transformed an insecure task management app into one that follows production security best practices. You've built a multi-layered defense that treats every Server Action as a
public API endpoint and protects it accordingly.What You Accomplished
- Runtime input validation with Zod — malformed or malicious data never reaches your business logic
- Instant UI feedback with
useActionStateanduseFormStatus— inline validation errors and pending states - Authentication via a Data Access Layer — every Server Action gated behind
verifySession - Ownership-based authorization — IDOR (Insecure Direct Object Reference) vulnerabilities eliminated
- Composable middleware pattern — the same approach used by production SaaS applications
Next Steps
To continue strengthening your skills:
- Rate limiting — add abuse prevention using a library like
@upstash/ratelimit - CSRF protection — Next.js provides built-in Origin header checks; understand how they work
next-safe-action— a popular library that formalizes the middleware pattern you built manually- Role-based authorization — for example, admin users can delete any task
- Real auth providers — integrate NextAuth.js v5 or Clerk to replace the mock session system
Security is not a feature you add later — it's a foundation you build from the start.
About the author
Real skill practice before real-world application
Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.
Learn by doing
Engage hands-on with the tools and technologies you’re learning. You pick the skill, we provide the credentials and environment.
Follow your guide
All labs have detailed instructions and objectives, guiding you through the learning process and ensuring you understand every step.
Turn time into mastery
On average, you retain 75% more of your learning if you take time to practice. Hands-on labs set you up for success to make those skills stick.