Difficulty: Intermediate
Input validation is one of the most critical aspects of building secure, reliable APIs. Every piece of data that comes from the client - route parameters, query strings, request bodies, headers - must be validated before use. Without validation, your application is vulnerable to SQL injection, XSS attacks, data corruption, and crashes from unexpected data types or formats.
There are three popular validation libraries for Express: express-validator, Joi, and Zod. express-validator is middleware-based and integrates directly into Express's middleware chain. Joi is a standalone schema description library that defines validation rules as JavaScript objects. Zod is a TypeScript-first schema library that provides both runtime validation and TypeScript type inference from the same schema definition.
express-validator uses a chain-based API: you call validation functions like body('email').isEmail().normalizeEmail() to define rules, then call validationResult(req) in your route handler to check for errors. It integrates seamlessly with Express because each validation rule is a middleware function. You can validate body fields, query parameters, route parameters, headers, and cookies with dedicated functions.
Zod has become increasingly popular because it provides type safety alongside validation. You define a schema like z.object({ name: z.string().min(2), email: z.string().email() }), then call schema.parse(req.body) or schema.safeParse(req.body) to validate. If validation fails, Zod throws a ZodError with detailed information about each validation failure. The safeParse variant returns a result object instead of throwing, which is often more convenient for API error handling.
Sanitization is the process of cleaning input data to prevent injection attacks and normalize formats. Common sanitization operations include trimming whitespace, converting email addresses to lowercase, escaping HTML special characters, and removing non-alphanumeric characters. Sanitization should be done after validation but before the data is used in database queries or rendered in HTML. Many validation libraries include built-in sanitization functions.
const express = require('express');
const { body, param, query, validationResult } = require('express-validator');
const app = express();
app.use(express.json());
// Reusable validation error handler
const validate = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
next();
};
// POST /api/users - validate body fields
app.post('/api/users', [
body('name')
.trim()
.isLength({ min: 2, max: 50 })
.withMessage('Name must be 2-50 characters'),
body('email')
.isEmail()
.withMessage('Must be a valid email')
.normalizeEmail(),
body('age')
.isInt({ min: 18, max: 120 })
.withMessage('Age must be between 18 and 120'),
body('password')
.isLength({ min: 8 })
.withMessage('Password must be at least 8 characters')
.matches(/[A-Z]/)
.withMessage('Password must contain an uppercase letter')
.matches(/[0-9]/)
.withMessage('Password must contain a number'),
validate
], (req, res) => {
res.status(201).json({ message: 'User created', data: req.body });
});
// GET /api/users/:id - validate params
app.get('/api/users/:id', [
param('id').isInt({ min: 1 }).withMessage('ID must be a positive integer'),
validate
], (req, res) => {
res.json({ id: parseInt(req.params.id), name: 'Alice' });
});
// GET /api/search?q=term&page=1 - validate query strings
app.get('/api/search', [
query('q').trim().notEmpty().withMessage('Search term is required'),
query('page').optional().isInt({ min: 1 }).withMessage('Page must be a positive integer'),
validate
], (req, res) => {
res.json({ query: req.query.q, page: parseInt(req.query.page) || 1 });
});
app.listen(3000, () => console.log('Server on port 3000'));
express-validator chains validation rules as middleware. body(), param(), and query() target different request data sources. The validate middleware checks for errors and returns 400 with error details if validation fails. .trim() and .normalizeEmail() are sanitizers that clean the data.
const express = require('express');
const { z } = require('zod');
const app = express();
app.use(express.json());
// Define schemas
const createUserSchema = z.object({
name: z.string().min(2).max(50).trim(),
email: z.string().email().toLowerCase(),
age: z.number().int().min(18).max(120),
role: z.enum(['user', 'admin']).default('user'),
tags: z.array(z.string()).max(10).optional()
});
const querySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sort: z.enum(['name', 'date', 'price']).default('date')
});
// Reusable validation middleware factory
function validateBody(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message
}))
});
}
req.body = result.data; // Replace with validated + transformed data
next();
};
}
function validateQuery(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.query);
if (!result.success) {
return res.status(400).json({ error: 'Invalid query parameters' });
}
req.query = result.data;
next();
};
}
app.post('/api/users', validateBody(createUserSchema), (req, res) => {
// req.body is now validated and typed
res.status(201).json({ message: 'User created', user: req.body });
});
app.get('/api/products', validateQuery(querySchema), (req, res) => {
// req.query.page is now a number, not a string
res.json({ page: req.query.page, limit: req.query.limit, sort: req.query.sort });
});
app.listen(3000, () => console.log('Server on port 3000'));
Zod schemas define both the structure and validation rules. safeParse() returns { success, data } or { success, error } without throwing. z.coerce.number() automatically converts query string values to numbers. The validated data replaces req.body/req.query with properly typed values.
const express = require('express');
const Joi = require('joi');
const app = express();
app.use(express.json());
// Define Joi schemas
const userSchema = Joi.object({
name: Joi.string().min(2).max(50).trim().required(),
email: Joi.string().email().lowercase().required(),
age: Joi.number().integer().min(18).max(120).required(),
phone: Joi.string().pattern(/^\+?[1-9]\d{1,14}$/).optional(),
address: Joi.object({
street: Joi.string().required(),
city: Joi.string().required(),
zip: Joi.string().pattern(/^\d{5,6}$/).required()
}).optional()
});
// Generic validation middleware
function validateSchema(schema, property = 'body') {
return (req, res, next) => {
const { error, value } = schema.validate(req[property], {
abortEarly: false, // Report ALL errors, not just the first
stripUnknown: true // Remove fields not in the schema
});
if (error) {
const errors = error.details.map(detail => ({
field: detail.path.join('.'),
message: detail.message
}));
return res.status(400).json({ error: 'Validation failed', details: errors });
}
req[property] = value; // Use sanitized/transformed values
next();
};
}
app.post('/api/users', validateSchema(userSchema), (req, res) => {
res.status(201).json({ message: 'User created', user: req.body });
});
app.listen(3000, () => console.log('Server on port 3000'));
// Example invalid request body:
// { name: "A", email: "not-an-email", age: 15 }
// Response:
// { error: 'Validation failed', details: [
// { field: 'name', message: '"name" length must be at least 2 characters long' },
// { field: 'email', message: '"email" must be a valid email' },
// { field: 'age', message: '"age" must be greater than or equal to 18' }
// ]}
Joi uses a fluent API to define validation rules. abortEarly: false reports all errors at once instead of stopping at the first. stripUnknown: true removes extra fields not defined in the schema, preventing injection of unexpected data.
express-validator, Joi, Zod, Sanitization, Error Messages, Validation Middleware