Featured resource
2025 Tech Upskilling Playbook
Tech Upskilling Playbook

Build future-ready tech teams and hit key business milestones with seven proven plays from industry leaders.

Check it out
  • Lab
    • Libraries: If you want this lab, consider one of these libraries.
    • Core Tech
Labs

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 platform
Lab Info
Last updated
Mar 24, 2026
Duration
30m

Contact sales

By clicking submit, you agree to our Privacy Policy and Terms of Use, and consent to receive marketing emails from Pluralsight.
Table of Contents
  1. 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.

  2. 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 FormData with 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 characters
    • description — optional string, max 500 characters
    • priority — 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() — unlike parse(), it never throws an exception. Instead it returns a
    discriminated union:

    • { success: true, data } — validation passed, data is fully typed
    • { success: false, error } — validation failed, error contains structured field-level details

    This lets you return structured validation errors to the client rather than crashing the server.

    Inside your createTask action, you'll extract fields from FormData using formData.get(), pass them to your schema's safeParse(), and return field-level errors if validation fails. ### Add an Update Task Schema with ID Validation

    Updating a task requires an additional field: the task's ID. Zod makes it easy to extend or compose schemas — you'll create an updateTaskSchema that includes all the fields from
    createTaskSchema plus an id field validated as a non-empty string.

    Zod's .extend() method lets you build on existing schemas without duplication.

  3. 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 useActionState hook (imported from "react", not "react-dom"), which pairs perfectly with Server Actions. It gives you three things:

    1. The current action state — including any errors your action returned
    2. A wrapped action function — to pass to your form's action prop
    3. An isPending boolean — 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 useActionState to the Task Creation Form

    The useActionState hook 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 action
    • formAction — 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 with useFormStatus

    While 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 a pending boolean that is true while the form's action is in flight.

    You'll create a SubmitButton component that disables itself and shows a loading indicator during submission.

  4. 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 verifySession function in lib/dal.ts that does the following:

    1. Reads the session cookie.
    2. Validates it.
    3. Returns the authenticated user's data, or null if 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. ### Gate createTask Behind Authentication

    Now you'll add an authentication check to the very beginning of your createTask Server Action. This is the critical security gate: if verifySession() returns null, 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 updateTask and deleteTask

    Authentication 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.

  5. 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 updateTask

    Before updating a task, you need to fetch it from the database and compare its ownerId to the authenticated user's userId. 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 deleteTask

    Apply 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 UI

    Authorization failures need to be communicated clearly. Update your form components to handle the message field 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.

  6. 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 useActionState and useFormStatus — 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

Zach is currently a Senior Software Engineer at VMware where he uses tools such as Python, Docker, Node, and Angular along with various Machine Learning and Data Science techniques/principles. Prior to his current role, Zach worked on submarine software and has a passion for GIS programming along with open-source software.

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.

Get started with Pluralsight