Always Validate Server Action Inputs

Server Actions are public HTTP endpoints. Always validate and sanitize all inputs using Zod or similar validation libraries to prevent security vulnerabilities.

intermediate 8 min read v14.0.0+

Always Validate Server Action Inputs

Server Actions in Next.js are exposed as public HTTP POST endpoints. Anyone can call them directly, bypassing your UI. Never trust incoming data.

The Security Risk

// DANGEROUS: No validation!
"use server";

export async function updateUser(formData: FormData) {
  const email = formData.get('email');
  const role = formData.get('role');

  // Attacker can set role to 'admin' directly!
  await db.user.update({
    where: { email },
    data: { role }
  });
}

An attacker can call this endpoint with:

curl -X POST https://yoursite.com/api/updateUser \
  -d "email=attacker@evil.com&role=admin"

Solution: Validate with Zod

Always validate inputs at the server action boundary:

"use server";

import { z } from 'zod';
import { revalidatePath } from 'next/cache';

// Define schema
const UpdateProfileSchema = z.object({
  email: z.string().email(),
  name: z.string().min(2).max(100),
  bio: z.string().max(500).optional(),
});

export async function updateProfile(formData: FormData) {
  // Parse and validate
  const result = UpdateProfileSchema.safeParse({
    email: formData.get('email'),
    name: formData.get('name'),
    bio: formData.get('bio'),
  });

  if (!result.success) {
    return { error: result.error.flatten() };
  }

  // Now data is validated and typed
  const { email, name, bio } = result.data;

  // Additional authorization check
  const session = await getSession();
  if (session.user.email !== email) {
    return { error: 'Unauthorized' };
  }

  await db.user.update({
    where: { email },
    data: { name, bio },
  });

  revalidatePath('/profile');
  return { success: true };
}

Validation Checklist

1. Type Validation

const schema = z.object({
  count: z.number().int().positive(),
  price: z.number().min(0),
  isActive: z.boolean(),
});

2. String Constraints

const schema = z.object({
  username: z.string()
    .min(3)
    .max(30)
    .regex(/^[a-zA-Z0-9_]+$/),
  email: z.string().email(),
  url: z.string().url(),
});

3. Enum Validation

// Don't trust string values!
const schema = z.object({
  role: z.enum(['user', 'editor']), // NOT 'admin'!
  status: z.enum(['draft', 'published']),
});

4. Array Limits

const schema = z.object({
  tags: z.array(z.string()).max(10),
  items: z.array(ItemSchema).min(1).max(100),
});

Complete Example with Error Handling

"use server";

import { z } from 'zod';

const CreatePostSchema = z.object({
  title: z.string().min(5).max(100),
  content: z.string().min(20).max(10000),
  tags: z.array(z.string().max(20)).max(5),
  published: z.boolean().default(false),
});

type CreatePostInput = z.infer<typeof CreatePostSchema>;

type ActionResult =
  | { success: true; postId: string }
  | { success: false; error: string | Record<string, string[]> };

export async function createPost(formData: FormData): Promise<ActionResult> {
  // 1. Authenticate
  const session = await getSession();
  if (!session) {
    return { success: false, error: 'Not authenticated' };
  }

  // 2. Parse form data
  const rawData = {
    title: formData.get('title'),
    content: formData.get('content'),
    tags: formData.getAll('tags'),
    published: formData.get('published') === 'true',
  };

  // 3. Validate
  const result = CreatePostSchema.safeParse(rawData);
  if (!result.success) {
    return {
      success: false,
      error: result.error.flatten().fieldErrors,
    };
  }

  // 4. Authorize (user can only create their own posts)
  // 5. Execute
  try {
    const post = await db.post.create({
      data: {
        ...result.data,
        authorId: session.user.id,
      },
    });

    revalidatePath('/posts');
    return { success: true, postId: post.id };
  } catch (error) {
    console.error('Failed to create post:', error);
    return { success: false, error: 'Failed to create post' };
  }
}

Anti-patterns

// BAD: Trusting client-side validation
"use server";
export async function updateUser(data: { role: string }) {
  // Client "validated" this, but we can't trust it!
  await db.user.update({ data: { role: data.role } });
}

// BAD: Using any/unknown without validation
"use server";
export async function processData(data: any) {
  // No idea what data contains!
  await doSomething(data);
}

// BAD: Partial validation
"use server";
export async function updateSettings(formData: FormData) {
  const theme = formData.get('theme');
  if (theme !== 'light' && theme !== 'dark') {
    throw new Error('Invalid theme');
  }
  // Forgot to validate other fields!
  const fontSize = formData.get('fontSize'); // Could be anything!
}

Security Summary

Check Implementation
Input validation Zod schema at entry point
Authentication Verify session exists
Authorization User can perform this action
Rate limiting Prevent abuse
Error handling Don't leak internal errors

Tags

security server-actions validation zod owasp

Meta

Author:
erold-team
Version:
1.0.0
Updated:
01/01/2025

Related Guidelines

input-sanitization sql-injection-prevention

API Endpoint

/api/v1/guidelines/nextjs/security/server-actions-validation.json