Input Validation and Sanitisation with express-validator

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
Note: Validation and sanitisation run as middleware โ€” they modify 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.
Tip: Use 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.
Warning: .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 &lt;script&gt; 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 &lt;Angular&gt;' in DB
// Angular displays: 'Learn <Angular>' โ€” correct but ugly; or worse: 'Learn &lt;Angular&gt;'

✅ 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()

🧠 Test Yourself

A client sends { title: "Buy milk", user: "hacked-id", role: "admin" } to POST /api/tasks. Using matchedData(req) where only title and priority are validated, what does matchedData return?