Skip to main content

βœ… 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 validate checkpoint

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.

graph LR A[Incoming request] --> B[Validation chains] B --> C[Sanitizers clean values] C --> D{validationResult empty?} D -->|Yes| E[Route handler] D -->|No| F[400 with field errors] E --> G[Success 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:

ValidatorChecks 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
Raw input flows through sanitizers into clean, typed values Raw input " Ada " "42" Sanitizers trim / escape toInt Clean value "Ada" 42
Sanitizers mutate 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 validate checkpoint
  • 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 validationResult check β€” chains alone never block a bad request
  • Escape passwords or exact-match fields with .escape()
  • Read error.param in v7 code β€” it's error.path now
  • Return the raw invalid value back 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 validate checkpoint for clean, DRY routes

πŸ“š Additional Resources

πŸš€ 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.