How-To GuideEmail
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
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
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"]
}
}
}