Never trust data that arrives from the client. Validation and sanitisation are the first line of defence for your API. Validation checks that data meets your requirements โ a required field is present, an email is formatted correctly, a number is within bounds. Sanitisation transforms potentially dangerous input into safe values โ trimming whitespace, stripping HTML tags, normalising email addresses. Without these, your application is vulnerable to malformed data crashing database operations, XSS attacks via stored HTML, and users bypassing business rules with carefully crafted inputs. This lesson builds a complete, reusable validation layer using express-validator.
express-validator Core Functions
| Function | Purpose | Example |
|---|---|---|
body(field) |
Validate a field in req.body |
body('email') |
param(field) |
Validate a URL parameter | param('id').isMongoId() |
query(field) |
Validate a query string value | query('page').isInt({ min: 1 }) |
header(field) |
Validate a request header | header('Authorization').exists() |
validationResult(req) |
Extract validation errors from the request | const errors = validationResult(req) |
matchedData(req) |
Extract only validated and sanitised values | const data = matchedData(req) |
Common Validators
| Validator | Checks |
|---|---|
.notEmpty() |
Value is not an empty string |
.isEmail() |
Valid email address format |
.isLength({ min, max }) |
String length within bounds |
.isInt({ min, max }) |
Integer within optional range |
.isFloat({ min, max }) |
Float within optional range |
.isIn([values]) |
Value is one of the allowed options |
.isMongoId() |
Valid MongoDB ObjectId (24-char hex) |
.isURL() |
Valid URL |
.isISO8601() |
Valid ISO 8601 date string |
.isStrongPassword() |
Password meets strength requirements |
.matches(regex) |
Matches custom regular expression |
.custom(fn) |
Custom async validator function |
.optional() |
Only validate if field is present |
.optional({ nullable: true }) |
Allow null values |
Common Sanitisers
| Sanitiser | Transforms |
|---|---|
.trim() |
Remove leading and trailing whitespace |
.escape() |
Replace HTML special chars with entities โ prevents XSS |
.normalizeEmail() |
Lowercase and normalise email address |
.toInt() |
Convert string to integer |
.toFloat() |
Convert string to float |
.toBoolean() |
Convert to boolean ('true' โ true) |
.toLowerCase() |
Convert string to lowercase |
.toUpperCase() |
Convert string to uppercase |
.default(value) |
Use default value if field is undefined |
req by attaching errors and sanitised values. They do NOT automatically reject invalid requests. You must check for errors using validationResult(req) in a subsequent middleware and return a 400 response if any errors exist. The common pattern is to define rules as an array, then pass the array and a validate middleware to the route in sequence.matchedData(req) after validation passes to extract only the fields you validated โ it ignores any extra fields the client may have sent. This prevents mass assignment vulnerabilities where a client sends fields like role: 'admin' or isVerified: true in the body. Only the explicitly validated fields make it through to your controller..escape() should NOT be applied to fields that will be stored and returned as-is (like task titles or descriptions) unless you also unescape them on output. Escaping stores <script> instead of <script> โ appropriate for HTML rendering but inappropriate for JSON APIs where Angular handles output encoding. For JSON APIs, rely on Mongoose schema types and parameterised queries to prevent injection rather than HTML-escaping stored data.Basic Example
const { body, param, query, validationResult, matchedData } = require('express-validator');
// โโ Reusable validation error handler middleware โโโโโโโโโโโโโโโโโโโโโโโโโโโ
const validate = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Validation failed',
errors: errors.array().map(e => ({
field: e.path,
message: e.msg,
value: e.value,
})),
});
}
next();
};
// โโ Registration validation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const registerRules = [
body('name')
.trim()
.notEmpty().withMessage('Name is required')
.isLength({ min: 2, max: 100 }).withMessage('Name must be 2-100 characters'),
body('email')
.trim()
.notEmpty().withMessage('Email is required')
.isEmail().withMessage('Must be a valid email address')
.normalizeEmail()
.custom(async (email) => {
const user = await User.findOne({ email });
if (user) throw new Error('Email already registered');
}),
body('password')
.notEmpty().withMessage('Password is required')
.isLength({ min: 8 }).withMessage('Password must be at least 8 characters')
.isStrongPassword({
minLowercase: 1,
minUppercase: 1,
minNumbers: 1,
minSymbols: 0,
}).withMessage('Password must contain uppercase, lowercase, and a number'),
body('confirmPassword')
.notEmpty().withMessage('Please confirm your password')
.custom((value, { req }) => {
if (value !== req.body.password) throw new Error('Passwords do not match');
return true;
}),
];
// โโ Task creation validation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const createTaskRules = [
body('title')
.trim()
.notEmpty().withMessage('Title is required')
.isLength({ min: 1, max: 200 }).withMessage('Title must be 1-200 characters'),
body('description')
.optional()
.trim()
.isLength({ max: 2000 }).withMessage('Description must be under 2000 characters'),
body('priority')
.optional()
.isIn(['low', 'medium', 'high'])
.withMessage('Priority must be low, medium, or high'),
body('dueDate')
.optional({ nullable: true })
.isISO8601().withMessage('Due date must be a valid ISO 8601 date')
.custom(value => {
if (value && new Date(value) < new Date()) {
throw new Error('Due date cannot be in the past');
}
return true;
}),
body('tags')
.optional()
.isArray({ max: 10 }).withMessage('Maximum 10 tags allowed')
.custom(tags => {
if (tags.some(t => typeof t !== 'string' || t.length > 50)) {
throw new Error('Each tag must be a string under 50 characters');
}
return true;
}),
];
// โโ URL parameter validation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const validateMongoId = [
param('id')
.isMongoId()
.withMessage('Invalid ID โ must be a 24-character hex string'),
];
// โโ Query string validation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const listTasksRules = [
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 between 1 and 100')
.toInt(),
query('status')
.optional()
.isIn(['pending', 'in-progress', 'completed'])
.withMessage('Invalid status value'),
query('priority')
.optional()
.isIn(['low', 'medium', 'high'])
.withMessage('Invalid priority value'),
query('sort')
.optional()
.matches(/^-?(createdAt|title|priority|dueDate)$/)
.withMessage('Invalid sort field'),
];
// โโ Route definitions โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const router = require('express').Router();
router.post('/register', registerRules, validate, authController.register);
router.get( '/', listTasksRules, validate, taskController.getAll);
router.post('/', createTaskRules, validate, taskController.create);
router.get( '/:id', validateMongoId, validate, taskController.getById);
Using matchedData for Mass-Assignment Protection
// controllers/auth.controller.js
const { matchedData } = require('express-validator');
const asyncHandler = require('../utils/asyncHandler');
const User = require('../models/user.model');
exports.register = asyncHandler(async (req, res) => {
// matchedData extracts ONLY the validated fields
// Even if client sends { role: 'admin', isVerified: true, ... }
// only name, email, password come through
const { name, email, password } = matchedData(req);
const user = await User.create({ name, email, password });
// Remove sensitive fields before responding
const userObj = user.toObject();
delete userObj.password;
res.status(201).json({ success: true, data: userObj });
});
// controllers/task.controller.js
exports.create = asyncHandler(async (req, res) => {
// Only title, description, priority, dueDate, tags come through
// Client cannot inject 'user', '_id', 'createdAt', 'isAdmin', etc.
const data = matchedData(req);
const task = await Task.create({ ...data, user: req.user.id });
res.status(201).json({ success: true, data: task });
});
Real-World Example: Centralised Validation Rules
// validators/auth.validators.js
const { body } = require('express-validator');
const User = require('../models/user.model');
exports.registerRules = [
body('name').trim().notEmpty().withMessage('Name is required')
.isLength({ min: 2, max: 100 }).withMessage('Name must be 2-100 characters'),
body('email').trim().isEmail().withMessage('Valid email required')
.normalizeEmail()
.custom(async email => {
if (await User.findOne({ email })) throw new Error('Email already in use');
}),
body('password').isLength({ min: 8 }).withMessage('Minimum 8 characters')
.isStrongPassword({ minUppercase: 1, minNumbers: 1, minSymbols: 0 })
.withMessage('Must include uppercase and a number'),
];
exports.loginRules = [
body('email').trim().isEmail().normalizeEmail().withMessage('Valid email required'),
body('password').notEmpty().withMessage('Password is required'),
];
exports.changePasswordRules = [
body('currentPassword').notEmpty().withMessage('Current password is required'),
body('newPassword').isLength({ min: 8 })
.isStrongPassword({ minUppercase: 1, minNumbers: 1, minSymbols: 0 })
.withMessage('New password must be strong'),
body('confirmPassword').custom((val, { req }) => {
if (val !== req.body.newPassword) throw new Error('Passwords do not match');
return true;
}),
];
// validators/task.validators.js
const { body, param, query } = require('express-validator');
exports.createRules = [ /* ... */ ];
exports.updateRules = [ /* ... */ ];
exports.idParam = [ param('id').isMongoId().withMessage('Invalid task ID') ];
exports.listQuery = [ /* ... */ ];
// routes/auth.routes.js
const { registerRules, loginRules } = require('../validators/auth.validators');
const { validate } = require('../middleware/validate');
router.post('/register', registerRules, validate, authController.register);
router.post('/login', loginRules, validate, authController.login);
Common Mistakes
Mistake 1 โ Not checking validationResult โ invalid data reaches the controller
โ Wrong โ validation middleware runs but errors are never checked:
router.post('/tasks', [
body('title').notEmpty(),
body('priority').isIn(['low', 'medium', 'high']),
// Missing: validate middleware!
], taskController.create);
// Controller receives invalid data โ Mongoose may throw or create bad records
✅ Correct โ always chain the validate middleware after rules:
router.post('/tasks', createTaskRules, validate, taskController.create);
Mistake 2 โ Using req.body directly instead of matchedData
โ Wrong โ client can inject unlisted fields:
exports.create = async (req, res) => {
const task = await Task.create(req.body); // { title, priority, user: 'hacked', role: 'admin' }
};
✅ Correct โ use matchedData to extract only validated fields:
exports.create = async (req, res) => {
const { title, priority, dueDate } = matchedData(req); // only validated fields
const task = await Task.create({ title, priority, dueDate, user: req.user.id });
};
Mistake 3 โ Applying .escape() to data stored and returned as JSON
โ Wrong โ HTML entities stored in database, displayed literally in Angular:
body('title').trim().escape() // stores 'Learn <Angular>' in DB
// Angular displays: 'Learn <Angular>' โ correct but ugly; or worse: 'Learn <Angular>'
✅ Correct โ trim and validate but let Angular handle output encoding:
body('title').trim().notEmpty().isLength({ max: 200 })
// Angular's {{ title }} interpolation escapes HTML automatically โ no need to escape in API
Quick Reference
| Task | Code |
|---|---|
| Validate body field | body('field').notEmpty().isEmail() |
| Validate URL param | param('id').isMongoId() |
| Validate query string | query('page').isInt({ min: 1 }).toInt() |
| Custom validator | .custom(async val => { if (!ok) throw new Error('msg') }) |
| Optional field | .optional() or .optional({ nullable: true }) |
| Check errors | const errors = validationResult(req); if (!errors.isEmpty()) ... |
| Extract safe data | const data = matchedData(req) |
| Sanitise whitespace | .trim() |
| Sanitise email | .normalizeEmail() |
| Convert to number | .toInt() or .toFloat() |