Episode 3 — NodeJS MongoDB Backend Architecture / 3.9 — REST API Development
3.9.e — Input Validation and Sanitization
In one sentence: Never trust client input — use
express-validatorfor declarative field-level checks orZodfor schema-based validation, and always sanitize data before it reaches your database or gets rendered in a response.
Navigation: <- 3.9.d — Status Codes in Practice | 3.9.f — API Security ->
1. Why Validate Input?
| Reason | Without validation | With validation |
|---|---|---|
| Security | SQL/NoSQL injection, XSS payloads stored in DB | Malicious input rejected before processing |
| Data integrity | Corrupt records, wrong types, empty fields | Clean, predictable data in every document |
| User experience | Cryptic 500 errors or silent failures | Clear error messages pointing to the exact problem |
| Debugging | "Why is age the string "banana"?" | Bad data never enters the system |
Client-Side vs Server-Side
Client-side validation Server-side validation
(UX convenience) (MANDATORY security)
| |
Can be bypassed Cannot be bypassed
by disabling JS, (runs on your server)
using curl, Postman,
or modifying requests
Golden rule: Client-side validation is a UX feature. Server-side validation is a security requirement. Always do both; never skip server-side.
2. express-validator: Setup
npm install express-validator
express-validator wraps the validator.js library and integrates directly with Express middleware chains.
const { body, param, query, validationResult } = require('express-validator');
| Function | What it validates |
|---|---|
body('field') | Fields in req.body |
param('field') | URL path parameters in req.params |
query('field') | Query string parameters in req.query |
header('field') | Request headers |
cookie('field') | Cookies |
3. Common Validators
String validators
body('name')
.isString().withMessage('Name must be a string')
.isLength({ min: 2, max: 50 }).withMessage('Name must be 2-50 characters')
.not().isEmpty().withMessage('Name is required'),
body('email')
.isEmail().withMessage('Must be a valid email address')
.normalizeEmail(), // sanitizer: lowercases, removes dots in gmail
body('password')
.isLength({ min: 8 }).withMessage('Password must be at least 8 characters')
.matches(/[A-Z]/).withMessage('Password must contain at least one uppercase letter')
.matches(/[0-9]/).withMessage('Password must contain at least one number')
.matches(/[!@#$%^&*]/).withMessage('Password must contain at least one special character'),
body('bio')
.optional() // field is not required
.isLength({ max: 500 }).withMessage('Bio cannot exceed 500 characters')
.trim() // sanitizer: removes leading/trailing whitespace
.escape() // sanitizer: HTML-encodes special characters
Numeric validators
body('age')
.isInt({ min: 13, max: 120 }).withMessage('Age must be an integer between 13 and 120')
.toInt(), // sanitizer: converts string "25" to number 25
body('price')
.isFloat({ min: 0.01, max: 99999.99 }).withMessage('Price must be between 0.01 and 99999.99')
.toFloat(),
query('page')
.optional()
.isInt({ min: 1 }).withMessage('Page must be a positive integer')
.toInt()
Other validators
body('website')
.optional()
.isURL().withMessage('Must be a valid URL'),
body('role')
.isIn(['user', 'admin', 'moderator']).withMessage('Invalid role'),
body('tags')
.isArray({ min: 1, max: 10 }).withMessage('Must provide 1-10 tags'),
body('tags.*') // validate each element in the array
.isString().withMessage('Each tag must be a string')
.trim()
.isLength({ min: 1, max: 30 }).withMessage('Each tag must be 1-30 characters'),
param('id')
.isMongoId().withMessage('Invalid ID format')
4. Checking for Validation Errors
const { validationResult } = require('express-validator');
app.post('/api/users',
// Validators run as middleware (in order)
body('name').isString().isLength({ min: 2, max: 50 }).withMessage('Name: 2-50 chars'),
body('email').isEmail().withMessage('Valid email required').normalizeEmail(),
body('password').isLength({ min: 8 }).withMessage('Password: min 8 chars'),
// Handler
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Input validation failed',
details: errors.array().map(err => ({
field: err.path,
message: err.msg,
value: err.value // the actual value that failed (careful with passwords)
}))
}
});
}
// Validation passed — proceed with creating user
// ...
}
);
Example error response
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Input validation failed",
"details": [
{
"field": "email",
"message": "Valid email required",
"value": "not-an-email"
},
{
"field": "password",
"message": "Password: min 8 chars",
"value": "abc"
}
]
}
}
5. Custom Validators
When built-in validators are not enough, write your own.
const { body } = require('express-validator');
const User = require('../models/User');
// Custom: check if email is already in use
body('email')
.isEmail()
.custom(async (email) => {
const existingUser = await User.findOne({ email });
if (existingUser) {
throw new Error('Email is already registered');
}
return true;
}),
// Custom: confirm password matches
body('confirmPassword')
.custom((value, { req }) => {
if (value !== req.body.password) {
throw new Error('Passwords do not match');
}
return true;
}),
// Custom: date must be in the future
body('eventDate')
.isISO8601().withMessage('Must be a valid ISO date')
.custom((value) => {
if (new Date(value) <= new Date()) {
throw new Error('Event date must be in the future');
}
return true;
}),
// Custom: at least one contact method
body('phone').optional(),
body('email').optional().isEmail(),
body().custom((value, { req }) => {
if (!req.body.phone && !req.body.email) {
throw new Error('At least one contact method (phone or email) is required');
}
return true;
})
6. Sanitization
Sanitizers transform input data. They run in the same chain as validators.
| Sanitizer | Effect | Example |
|---|---|---|
trim() | Remove leading/trailing whitespace | " Alice " -> "Alice" |
escape() | HTML-encode <, >, &, ', " | "<script>" -> "<script>" |
normalizeEmail() | Lowercase, remove dots (Gmail) | "Alice@Gmail.Com" -> "alice@gmail.com" |
toInt() | Convert string to integer | "42" -> 42 |
toFloat() | Convert string to float | "3.14" -> 3.14 |
toBoolean() | Convert to boolean | "true" -> true |
blacklist(chars) | Remove specific characters | Remove $ from input |
whitelist(chars) | Keep only specific characters | Keep only alphanumeric |
stripLow() | Remove control characters (ASCII 0-31) | Remove null bytes |
app.post('/api/users',
body('name').trim().escape().isLength({ min: 2, max: 50 }),
body('email').trim().normalizeEmail().isEmail(),
body('age').optional().toInt().isInt({ min: 13 }),
body('bio').optional().trim().escape().isLength({ max: 500 }),
body('website').optional().trim().isURL(),
(req, res) => {
// req.body.name is now trimmed and HTML-escaped
// req.body.email is normalized
// req.body.age is now an integer (not a string)
}
);
7. Zod: Schema-Based Validation
Zod is a TypeScript-first schema validation library that works great with Express. It defines the shape of your data as a schema object.
npm install zod
Basic Zod schemas
const { z } = require('zod');
// Define a schema
const createUserSchema = z.object({
name: z.string()
.min(2, 'Name must be at least 2 characters')
.max(50, 'Name cannot exceed 50 characters'),
email: z.string()
.email('Must be a valid email address')
.transform(val => val.toLowerCase()), // sanitize
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Must contain an uppercase letter')
.regex(/[0-9]/, 'Must contain a number'),
age: z.number()
.int('Age must be a whole number')
.min(13, 'Must be at least 13')
.max(120, 'Must be at most 120')
.optional(),
role: z.enum(['user', 'admin', 'moderator']).default('user'),
tags: z.array(z.string().min(1).max(30))
.min(1, 'At least one tag required')
.max(10, 'Maximum 10 tags')
.optional(),
profile: z.object({
bio: z.string().max(500).optional(),
avatar: z.string().url('Must be a valid URL').optional()
}).optional()
});
.parse() vs .safeParse()
// .parse() — throws ZodError if validation fails
try {
const userData = createUserSchema.parse(req.body);
// userData is typed and validated
} catch (err) {
// err is a ZodError with detailed issues
}
// .safeParse() — returns a result object (no exceptions)
const result = createUserSchema.safeParse(req.body);
if (!result.success) {
console.log(result.error.issues);
// [{ code: 'too_small', minimum: 8, path: ['password'], message: '...' }]
} else {
console.log(result.data); // validated data
}
8. Validation Middleware Pattern
With express-validator
// src/middleware/validate.js
const { validationResult } = require('express-validator');
const validate = (validations) => {
return async (req, res, next) => {
// Run all validations
await Promise.all(validations.map(validation => validation.run(req)));
const errors = validationResult(req);
if (errors.isEmpty()) return next();
res.status(400).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Input validation failed',
details: errors.array().map(err => ({
field: err.path,
message: err.msg
}))
}
});
};
};
module.exports = validate;
// src/validators/userValidators.js
const { body, param } = require('express-validator');
const createUserValidation = [
body('name').trim().isString().isLength({ min: 2, max: 50 }).withMessage('Name: 2-50 chars'),
body('email').trim().normalizeEmail().isEmail().withMessage('Valid email required'),
body('password').isLength({ min: 8 }).withMessage('Password: min 8 chars'),
body('role').optional().isIn(['user', 'admin']).withMessage('Role must be user or admin')
];
const updateUserValidation = [
param('id').isMongoId().withMessage('Invalid user ID'),
body('name').optional().trim().isString().isLength({ min: 2, max: 50 }),
body('email').optional().trim().normalizeEmail().isEmail()
];
module.exports = { createUserValidation, updateUserValidation };
// src/routes/users.js
const validate = require('../middleware/validate');
const { createUserValidation, updateUserValidation } = require('../validators/userValidators');
router.post('/users', validate(createUserValidation), userController.create);
router.patch('/users/:id', validate(updateUserValidation), userController.update);
With Zod
// src/middleware/zodValidate.js
const zodValidate = (schema) => {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Input validation failed',
details: result.error.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message,
code: issue.code
}))
}
});
}
req.body = result.data; // replace body with parsed/transformed data
next();
};
};
module.exports = zodValidate;
// src/schemas/userSchemas.js
const { z } = require('zod');
const createUserSchema = z.object({
name: z.string().min(2).max(50),
email: z.string().email().transform(v => v.toLowerCase()),
password: z.string().min(8).regex(/[A-Z]/).regex(/[0-9]/),
role: z.enum(['user', 'admin']).default('user')
});
const updateUserSchema = createUserSchema.partial().omit({ password: true });
// .partial() makes all fields optional
// .omit() removes fields
module.exports = { createUserSchema, updateUserSchema };
// src/routes/users.js
const zodValidate = require('../middleware/zodValidate');
const { createUserSchema, updateUserSchema } = require('../schemas/userSchemas');
router.post('/users', zodValidate(createUserSchema), userController.create);
router.patch('/users/:id', zodValidate(updateUserSchema), userController.update);
9. express-validator vs Zod Comparison
| Criteria | express-validator | Zod |
|---|---|---|
| Approach | Middleware chain per field | Schema object definition |
| TypeScript | Manual types needed | Infers types from schema |
| Sanitization | Built-in (trim, escape) | Via .transform() |
| Nested objects | Dot notation (body('profile.bio')) | Native (z.object({ profile: z.object() })) |
| Reusability | Array of validators | Schema composition (.extend(), .merge()) |
| Async validation | .custom(async ...) | .refine(async ...) |
| Learning curve | Low (Express-familiar) | Medium (new API to learn) |
| Beyond Express | Express-specific | Works anywhere (frontend, backend, scripts) |
Recommendation: Use express-validator if you want quick, Express-native validation. Use Zod if you want type inference, schema reuse, or plan to share validation logic between frontend and backend.
10. Real Example: User Registration Validation
// Complete registration endpoint with express-validator
const express = require('express');
const router = express.Router();
const { body } = require('express-validator');
const validate = require('../middleware/validate');
const User = require('../models/User');
const bcrypt = require('bcrypt');
router.post('/register',
validate([
body('name')
.trim()
.isString()
.isLength({ min: 2, max: 50 })
.withMessage('Name must be 2-50 characters'),
body('email')
.trim()
.normalizeEmail()
.isEmail()
.withMessage('Valid email required')
.custom(async (email) => {
const existing = await User.findOne({ email });
if (existing) throw new Error('Email already registered');
}),
body('password')
.isLength({ min: 8 })
.withMessage('Password: min 8 characters')
.matches(/[A-Z]/)
.withMessage('Password needs an uppercase letter')
.matches(/[0-9]/)
.withMessage('Password needs a number'),
body('confirmPassword')
.custom((value, { req }) => {
if (value !== req.body.password) throw new Error('Passwords do not match');
return true;
})
]),
async (req, res, next) => {
try {
const { name, email, password } = req.body;
const hashedPassword = await bcrypt.hash(password, 12);
const user = await User.create({ name, email, password: hashedPassword });
res.status(201).json({
data: { id: user._id, name: user.name, email: user.email }
});
} catch (err) {
next(err);
}
}
);
module.exports = router;
11. Real Example: Search Query Validation
// GET /api/products?q=laptop&category=electronics&minPrice=100&maxPrice=2000&page=1&limit=20
router.get('/products',
validate([
query('q')
.optional()
.trim()
.escape()
.isLength({ min: 1, max: 100 })
.withMessage('Search query: 1-100 characters'),
query('category')
.optional()
.isIn(['electronics', 'clothing', 'books', 'home', 'sports'])
.withMessage('Invalid category'),
query('minPrice')
.optional()
.isFloat({ min: 0 })
.withMessage('Min price must be >= 0')
.toFloat(),
query('maxPrice')
.optional()
.isFloat({ min: 0 })
.withMessage('Max price must be >= 0')
.toFloat()
.custom((value, { req }) => {
if (req.query.minPrice && value < parseFloat(req.query.minPrice)) {
throw new Error('Max price must be >= min price');
}
return true;
}),
query('page')
.optional()
.isInt({ min: 1 })
.withMessage('Page must be a positive integer')
.toInt(),
query('limit')
.optional()
.isInt({ min: 1, max: 100 })
.withMessage('Limit must be 1-100')
.toInt()
]),
async (req, res) => {
const { q, category, minPrice, maxPrice, page = 1, limit = 20 } = req.query;
const filter = {};
if (q) filter.name = { $regex: q, $options: 'i' };
if (category) filter.category = category;
if (minPrice || maxPrice) {
filter.price = {};
if (minPrice) filter.price.$gte = minPrice;
if (maxPrice) filter.price.$lte = maxPrice;
}
const products = await Product.find(filter)
.skip((page - 1) * limit)
.limit(limit);
res.json({ data: products, page, limit });
}
);
12. Key Takeaways
- Never trust client input — always validate on the server, even if you validate on the client too.
express-validatorgives you per-field middleware validation withbody(),param(),query(), andvalidationResult().Zodgives you schema-based validation with TypeScript type inference and works anywhere (not Express-specific).- Sanitize inputs to prevent XSS (
escape()), normalize data (trim(),normalizeEmail()), and convert types (toInt()). - Extract validation into reusable middleware — do not repeat validation logic in every route handler.
Explain-It Challenge
Explain without notes:
- Why is server-side validation non-negotiable even when you have client-side validation?
- Walk through how a validation middleware pattern works from request to response (or error).
- When would you choose Zod over express-validator, and vice versa?
Navigation: <- 3.9.d — Status Codes in Practice | 3.9.f — API Security ->