β Input Validation with Express-validator
Every byte a client sends you is a promise that might be broken. A form says "email" but sends "lol"; an API caller sends a price of -50; an attacker sends a <script> tag hoping you'll store it. Input validation is the checkpoint that inspects each request before it reaches your business logic β and express-validator makes building that checkpoint declarative and clean.
Week 7 · Day 5 (Friday: Error Handling and Validation) · Lecture 2
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain why server-side validation is non-negotiable, even with client-side checks
- Build validation chains for body, params, and query with
body(),param(),query() - Collect results with
validationResult(req)and return clean 400 responses - Sanitize input β trim, normalize, escape β before it touches your logic
- Write custom and conditional validators, including cross-field checks
- Package validation into reusable middleware with a single
validatecheckpoint
Estimated Time: 60 minutes
Practice: Build a registration validator with sanitizers, a password-match rule, and a reusable result handler.
In This Lesson
Why Validate on the Server?
Client-side validation β the red text that appears under a form field β is a user experience feature. It gives instant feedback and saves a round trip. But it is not security, because anyone can bypass it: disable JavaScript, use curl, or call your API directly with Postman. The browser is not your gatekeeper. Your server is.
Think of it like airport security. The friendly reminder signs before the checkpoint (client-side) help travelers prepare, but the actual screening (server-side) is what keeps dangerous things off the plane. You screen every passenger regardless of what the signs said. Validate every request regardless of what the front end claims it sent.
- Security β reject injection payloads, malformed data, and oversized input before they do harm
- Data integrity β guarantee that what reaches your database matches your schema's expectations
- Stability β a missing field or wrong type won't throw deep inside your logic where it's hard to trace
- Clear feedback β return precise, per-field errors clients can act on
express-validator is a set of Express middleware wrapping the battle-tested validator.js library. It lets you declare rules as fluent chains that read almost like English.
The Validate β Sanitize β Handle Pipeline
Every validated route follows the same three-stage flow. Requests enter, get checked and cleaned, and only valid ones reach your handler; invalid ones peel off into a 400 response.
First install the package:
// npm install express-validator
Here's the whole pipeline in one route so you can see the shape before we break it down:
const express = require('express');
const { body, validationResult } = require('express-validator');
const app = express();
app.use(express.json());
app.post(
'/api/users',
// 1) Validate + sanitize (each is a middleware in the chain)
body('name').trim().notEmpty().withMessage('Name is required'),
body('email').trim().isEmail().withMessage('Valid email required').normalizeEmail(),
body('password').isLength({ min: 8 }).withMessage('Min 8 characters'),
// 2) Handle β inspect the collected results
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// 3) Only valid, cleaned data reaches here
res.status(201).json({ message: 'User created', user: req.body });
}
);
app.listen(3000, () => console.log('Running on 3000'));
Notice validation rules are just middleware you pass before the handler. They run in order, accumulate any problems on the request, and your handler decides what to do with them.
Validation Chains
A validation chain starts by naming a field from a request location, then fluently appends rules. Each rule returns the chain, so they read top-to-bottom like a checklist. The location functions are body(), param(), query(), header(), and cookie().
const { body, param, query } = require('express-validator');
// Body field: presence, then length
body('username')
.trim()
.notEmpty().withMessage('Username is required')
.isLength({ min: 3, max: 20 }).withMessage('Must be 3-20 characters');
// URL parameter: numeric id
param('id')
.isInt({ min: 1 }).withMessage('id must be a positive integer')
.toInt();
// Query string: optional pagination limit
query('limit')
.optional()
.isInt({ min: 1, max: 100 }).withMessage('limit must be 1-100')
.toInt();
express-validator ships dozens of validators. A sampler of the most common:
| Validator | Checks that the value⦠|
|---|---|
.notEmpty() | is not an empty string |
.isEmail() | looks like a valid email address |
.isLength({ min, max }) | has a string length in range |
.isInt({ min, max }) / .isFloat() | is an integer / number in range |
.isIn([...]) | is one of an allowed set |
.matches(/regex/) | matches a pattern |
.isBoolean(), .isURL(), .isISO8601() | is a boolean / URL / date |
.optional() | may be absent β skip remaining rules if so |
π .bail() stops the chain early
By default every validator in a chain runs and reports. Add .bail() after a check to stop that chain if the check fails β useful when a later rule (like an expensive database lookup) only makes sense once earlier ones pass: body('email').isEmail().bail().custom(checkUnique).
Checking the Result
The chains only gather findings β they never respond on their own. To act on them, call validationResult(req), which returns a result object. Its .isEmpty() tells you if everything passed, and .array() gives the list of failures.
const { validationResult } = require('express-validator');
app.post('/api/users', /* ...chains... */ (req, res) => {
const result = validationResult(req);
if (!result.isEmpty()) {
return res.status(400).json({ errors: result.array() });
}
// proceed with valid data
});
Output β a sample 400 response
{
"errors": [
{ "type": "field", "path": "email",
"location": "body", "value": "not-an-email",
"msg": "Valid email required" },
{ "type": "field", "path": "password",
"location": "body", "value": "123",
"msg": "Min 8 characters" }
]
}
You can reshape that output with .formatWith() to match your API's conventions β for example, a slimmer { field, message } object per error:
const errors = validationResult(req).formatWith(({ path, msg }) => ({
field: path,
message: msg
}));
if (!errors.isEmpty()) {
return res.status(400).json({ success: false, errors: errors.array() });
}
π‘ A note on the modern shape
express-validator v7 renamed the error fields: the field name is now path (older tutorials show param) and each error has a type. If you're reading older code that destructures error.param, update it to error.path.
Sanitization
Validation asks "is this acceptable?" Sanitization asks "let me clean this up first." Sanitizers transform the value in place, so by the time your handler reads req.body, the data is already normalized. Run sanitizers before validators in the chain so you validate the cleaned value.
body('name')
.trim() // strip leading/trailing whitespace
.escape() // convert <, >, &, ", ' to HTML entities
.notEmpty().withMessage('Name is required');
body('email')
.trim()
.normalizeEmail() // lowercase domain, canonicalize
.isEmail().withMessage('Valid email required');
body('age')
.toInt(); // "42" (string) becomes 42 (number)
body('subscribe')
.toBoolean(); // "true"/"1" becomes true
req.body in place β your handler receives already-cleaned, correctly-typed values.β οΈ .escape() is for display, not everything
Escaping HTML entities is great for text you'll later render into a page (it neutralizes stored XSS). But don't escape values like passwords or fields you'll compare exactly β it will corrupt them. Apply .escape() only where the value ends up as HTML.
Custom & Conditional Validators
Built-in validators cover the common cases, but real apps have business rules: "this email must be unique," "confirm password must match," "shipping address is required only for physical products." That's what .custom() is for. A custom validator receives the value (and a context with req), returns true to pass, or throws / returns a rejected promise to fail.
Cross-field validation
body('passwordConfirm')
.custom((value, { req }) => {
if (value !== req.body.password) {
throw new Error('Passwords do not match');
}
return true; // explicit pass
});
Async custom validators (e.g. uniqueness)
body('email')
.isEmail().withMessage('Valid email required').bail()
.custom(async (email) => {
const existing = await User.findOne({ email });
if (existing) {
// A rejected promise fails the validation with this message
throw new Error('Email is already registered');
}
return true;
});
Conditional rules with .if()
// Only validate shippingAddress when the order is physical
body('shippingAddress')
.if(body('productType').equals('physical'))
.notEmpty().withMessage('Shipping address is required for physical goods');
π‘ throw vs return the message
Inside a custom validator, the cleanest way to fail is to throw new Error('message') β the thrown message becomes the field's error. Returning false also fails it, but then you'd rely on a trailing .withMessage(). Throwing keeps the message next to the rule that produced it.
Reusable Validation Middleware
Sprinkling validationResult checks into every handler repeats the same six lines everywhere. Extract that into one validate middleware, then group each route's chains into an exported array. Routes become a clean list: rules, the checkpoint, the handler.
// middleware/validate.js
const { validationResult } = require('express-validator');
// One checkpoint: if any chain failed, respond 400; else continue.
function validate(req, res, next) {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
errors: errors.array().map(({ path, msg }) => ({ field: path, message: msg }))
});
}
next();
}
module.exports = validate;
// validators/user.js
const { body } = require('express-validator');
const registerRules = [
body('name').trim().notEmpty().withMessage('Name is required')
.isLength({ min: 2, max: 50 }).withMessage('Name must be 2-50 characters'),
body('email').trim().normalizeEmail()
.isEmail().withMessage('Valid email required'),
body('password').isLength({ min: 8 }).withMessage('Min 8 characters')
.matches(/\d/).withMessage('Must contain a number'),
body('passwordConfirm').custom((v, { req }) => {
if (v !== req.body.password) throw new Error('Passwords do not match');
return true;
})
];
module.exports = { registerRules };
// routes/users.js
const express = require('express');
const router = express.Router();
const validate = require('../middleware/validate');
const { registerRules } = require('../validators/user');
const controller = require('../controllers/user');
// rules β checkpoint β handler
router.post('/register', registerRules, validate, controller.register);
module.exports = router;
Now each route reads as intent: "apply these rules, run the checkpoint, then do the work." Adding a field means editing one array; the checkpoint never changes.
Practice & Quiz
ποΈ Exercise 1: A registration validator
Goal: Write a registerRules array for a signup endpoint with these rules: username 3β20 chars, letters/numbers/underscore only, trimmed; email valid and normalized; age optional integer 13β120; acceptTerms must be true.
π‘ Hint
Use .matches(/^\w+$/) for the username pattern, .optional() before the age rules, and .equals('true') (or a custom check) for the terms checkbox. Remember to .trim() and .normalizeEmail() as sanitizers first.
β Solution
const { body } = require('express-validator');
const registerRules = [
body('username')
.trim()
.isLength({ min: 3, max: 20 }).withMessage('Username must be 3-20 characters')
.matches(/^\w+$/).withMessage('Letters, numbers and underscore only'),
body('email')
.trim()
.normalizeEmail()
.isEmail().withMessage('Valid email required'),
body('age')
.optional()
.isInt({ min: 13, max: 120 }).withMessage('Age must be 13-120')
.toInt(),
body('acceptTerms')
.equals('true').withMessage('You must accept the terms')
];
module.exports = { registerRules };
ποΈ Exercise 2: Reject a negative price
Goal: A product route accepts price. Write a chain that requires it, ensures it's a number β₯ 0.01, and converts the string to a float so the handler gets a real number.
β Solution
body('price')
.notEmpty().withMessage('Price is required').bail()
.isFloat({ min: 0.01 }).withMessage('Price must be at least 0.01')
.toFloat();
π― Quick Quiz
Question 1: Why is server-side validation required even when the form already validates in the browser?
Question 2: After the validation chains run, how do you find out whether anything failed?
Question 3: Where should sanitizers like .trim() go relative to validators?
Best Practices & Pitfalls
β Do
- Validate every input source: body, params, query, and any headers you trust
- Sanitize before you validate β
.trim(),.normalizeEmail(),.toInt() - Extract chains into reusable arrays and use one shared
validatecheckpoint - Use
.optional()for update endpoints where fields may be absent - Add
.bail()before expensive checks like database uniqueness lookups
β Don't
- Trust client-side validation as a security boundary
- Forget the
validationResultcheck β chains alone never block a bad request - Escape passwords or exact-match fields with
.escape() - Read
error.paramin v7 code β it'serror.pathnow - Return the raw invalid
valueback in errors for sensitive fields (like passwords)
β Pair validation with centralized error handling
Instead of building the 400 response inside validate, you can throw a ValidationError and let the central error handler from the previous lesson shape it β giving validation failures the exact same response format as every other error. That's the bridge into the next lesson.
Summary
π Key Takeaways
- Server-side validation is a security requirement, not a nicety β never trust the client
- Rules are fluent validation chains from
body(),param(),query() - Chains only gather findings;
validationResult(req)is where you act on them - Sanitize before validate so cleaned, correctly-typed values reach your handler
.custom()handles business rules (uniqueness, password match);.if()handles conditional rules- Extract chains into arrays and a single
validatecheckpoint for clean, DRY routes
π Additional Resources
- express-validator β Official documentation
- express-validator β The validation chain
- validator.js β the underlying validators
- OWASP β Input Validation Cheat Sheet
π What's Next?
You're now throwing Error objects and setting statusCode by hand all over the place. The next lesson cleans that up for good: Custom Error Classes, where you'll build an AppError hierarchy β ValidationError, NotFoundError, and friends β so throwing the right error with the right status becomes a single, self-documenting line.
π Bad data stops at the door
Your routes now receive only clean, validated input β and clients get precise feedback when they don't send it. That's a huge leap in both security and polish.