How to Validate User Input Securely

Share
How-To Guide

How to Validate User Input Securely

Client-side validation is for UX. Server-side validation is for security.

TL;DR

TL;DR

Always validate input on the server, even if you have client-side validation. Use Zod to define schemas. Check types, lengths, formats, and allowed values. Reject invalid input early with clear error messages.

Why Client-Side Validation Isn't Enough

Users can bypass client-side validation by modifying JavaScript, using browser DevTools, or calling your API directly. Never trust client-side validation for security.

Step-by-Step with Zod

1

Install Zod

npm install zod
2

Define your schema

import { z } from 'zod';

const CreateUserSchema = z.object({
  email: z.string()
    .email('Invalid email format')
    .max(254, 'Email too long'),

  password: z.string()
    .min(8, 'Password must be at least 8 characters')
    .max(100, 'Password too long'),

  name: z.string()
    .min(1, 'Name is required')
    .max(100, 'Name too long')
    .regex(/^[a-zA-Z\s]+$/, 'Name can only contain letters'),

  age: z.number()
    .int('Age must be a whole number')
    .min(13, 'Must be at least 13')
    .max(120, 'Invalid age')
    .optional(),
});
3

Validate in your API route

export async function POST(request: Request) {
  const body = await request.json();

  // Validate input
  const result = CreateUserSchema.safeParse(body);

  if (!result.success) {
    return Response.json(
      { error: 'Validation failed', details: result.error.flatten() },
      { status: 400 }
    );
  }

  // result.data is now typed and validated
  const { email, password, name, age } = result.data;

  // Safe to use these values
  const user = await createUser({ email, password, name, age });

  return Response.json(user);
}

Common Validation Patterns

Email

const email = z.string()
  .email('Invalid email')
  .max(254)
  .toLowerCase(); // Normalize to lowercase

Password

const password = z.string()
  .min(8, 'Password too short')
  .max(100, 'Password too long')
  .regex(/[A-Z]/, 'Must contain uppercase')
  .regex(/[a-z]/, 'Must contain lowercase')
  .regex(/[0-9]/, 'Must contain number');

URL

const url = z.string()
  .url('Invalid URL')
  .startsWith('https://', 'Must use HTTPS');

UUID

const id = z.string().uuid('Invalid ID format');

Enum Values

const status = z.enum(['draft', 'published', 'archived']);

Arrays

const tags = z.array(z.string())
  .min(1, 'At least one tag required')
  .max(10, 'Maximum 10 tags');

Dates

const birthDate = z.string()
  .datetime()
  .refine((date) => new Date(date) < new Date(), {
    message: 'Birth date must be in the past',
  });

Form with React Hook Form

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

const schema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

type FormData = z.infer<typeof schema>;

function SignupForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
    resolver: zodResolver(schema),
  });

  const onSubmit = async (data: FormData) => {
    // Client-side validated, but STILL validate server-side!
    const response = await fetch('/api/signup', {
      method: 'POST',
      body: JSON.stringify(data),
    });
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}

      <input type="password" {...register('password')} />
      {errors.password && <span>{errors.password.message}</span>}

      <button type="submit">Sign Up</button>
    </form>
  );
}

What to Validate

  • Type: Is it a string, number, boolean, array?
  • Required: Is this field optional or mandatory?
  • Length: Min/max characters for strings, items for arrays
  • Format: Email, URL, UUID, date format
  • Range: Min/max values for numbers
  • Allowed values: Enums for status fields
  • Custom rules: Business logic validation

Error Handling Best Practices

  • Return specific error messages for each field
  • Don't reveal internal details in errors
  • Use 400 status code for validation errors
  • Return errors in a consistent format
// Good error response
{
  "error": "Validation failed",
  "details": {
    "fieldErrors": {
      "email": ["Invalid email format"],
      "password": ["Password too short"]
    }
  }
}
How-To Guides

How to Validate User Input Securely