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-validator for declarative field-level checks or Zod for 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?

ReasonWithout validationWith validation
SecuritySQL/NoSQL injection, XSS payloads stored in DBMalicious input rejected before processing
Data integrityCorrupt records, wrong types, empty fieldsClean, predictable data in every document
User experienceCryptic 500 errors or silent failuresClear 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');
FunctionWhat 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.

SanitizerEffectExample
trim()Remove leading/trailing whitespace" Alice " -> "Alice"
escape()HTML-encode <, >, &, ', ""<script>" -> "&lt;script&gt;"
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 charactersRemove $ from input
whitelist(chars)Keep only specific charactersKeep 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

Criteriaexpress-validatorZod
ApproachMiddleware chain per fieldSchema object definition
TypeScriptManual types neededInfers types from schema
SanitizationBuilt-in (trim, escape)Via .transform()
Nested objectsDot notation (body('profile.bio'))Native (z.object({ profile: z.object() }))
ReusabilityArray of validatorsSchema composition (.extend(), .merge())
Async validation.custom(async ...).refine(async ...)
Learning curveLow (Express-familiar)Medium (new API to learn)
Beyond ExpressExpress-specificWorks 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

  1. Never trust client input — always validate on the server, even if you validate on the client too.
  2. express-validator gives you per-field middleware validation with body(), param(), query(), and validationResult().
  3. Zod gives you schema-based validation with TypeScript type inference and works anywhere (not Express-specific).
  4. Sanitize inputs to prevent XSS (escape()), normalize data (trim(), normalizeEmail()), and convert types (toInt()).
  5. Extract validation into reusable middleware — do not repeat validation logic in every route handler.

Explain-It Challenge

Explain without notes:

  1. Why is server-side validation non-negotiable even when you have client-side validation?
  2. Walk through how a validation middleware pattern works from request to response (or error).
  3. When would you choose Zod over express-validator, and vice versa?

Navigation: <- 3.9.d — Status Codes in Practice | 3.9.f — API Security ->