Skip to content

Input Validation

Every input is guilty until proven innocent. Trust nothing. Validate everything
Attackers don't follow your API spec. They send strings where numbers belong , objects where strings belong , null where objects belong , and SQL where anything belongs. Your validation layer is the last line of defense before your database. Make it count

express-validator

The most popular validation library for Express , built on top of validator.js

npm install express-validator
const { body , param , query , validationResult } = require('express-validator')

app.post('/users',
  [
    body('email')
      .isEmail()
      .normalizeEmail()
      .withMessage('Valid email is required'),
    body('password')
      .isLength({ min: 8 , max: 128 })
      .matches(/[A-Z]/).withMessage('Must contain uppercase')
      .matches(/[a-z]/).withMessage('Must contain lowercase')
      .matches(/[0-9]/).withMessage('Must contain number')
      .withMessage('Password must be 8+ chars with upper , lower , and number'),
    body('age')
      .optional()
      .isInt({ min: 0 , max: 150 })
      .toInt()
  ],
  (req , res) => {
    const errors = validationResult(req)
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() })
    }
    // safe to proceed
    res.json({ valid: true })
  }
)

Joi - schema validation

Joi defines validation schemas as objects. Cleaner than chained validators

npm install joi
const Joi = require('joi')

const userSchema = Joi.object({
  email: Joi.string().email().required(),
  password: Joi.string().min(8).max(128)
    .pattern(/[A-Z]/ , 'uppercase')
    .pattern(/[a-z]/ , 'lowercase')
    .pattern(/[0-9]/ , 'number')
    .required(),
  name: Joi.string().min(2).max(100).trim().required(),
  age: Joi.number().integer().min(0).max(150).optional(),
  role: Joi.string().valid('user' , 'admin').default('user')
})

// validation middleware
const validate = (schema) => (req , res , next) => {
  const { error , value } = schema.validate(req.body , {
    abortEarly: false,    // return all errors not just first
    stripUnknown: true    // remove unknown fields
  })

  if (error) {
    return res.status(422).json({
      error: 'Validation failed',
      details: error.details.map(e => ({
        field: e.path.join('.'),
        message: e.message
      }))
    })
  }

  // replace req.body with validated and sanitized data
  req.body = value
  next()
}

// usage
app.post('/users' , validate(userSchema) , createUserHandler)

Zod - TypeScript-first validation

Zod is Schema validation with first-class TypeScript support

npm install zod
const { z } = require('zod')

const userSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8).max(128)
    .regex(/[A-Z]/ , 'Must contain uppercase')
    .regex(/[a-z]/ , 'Must contain lowercase')
    .regex(/[0-9]/ , 'Must contain number'),
  name: z.string().min(2).max(100).trim(),
  age: z.number().int().positive().max(150).optional(),
  role: z.enum(['user' , 'admin']).default('user')
})

// middleware
const validate = (schema) => (req , res , next) => {
  const result = schema.safeParse(req.body)
  if (!result.success) {
    return res.status(422).json({
      error: 'Validation failed',
      details: result.error.issues.map(i => ({
        field: i.path.join('.'),
        message: i.message
      }))
    })
  }
  req.body = result.data
  next()
}

sanitization

Validation rejects bad data. Sanitization cleans ambiguous data

const { body } = require('express-validator')

app.post('/comment',
  [
    body('text')
      .trim()                    // remove leading/trailing whitespace
      .escape()                  // convert HTML entities (< -> &lt;)
      .isLength({ min: 1 , max: 1000 }),
    body('email')
      .normalizeEmail()          // lowercase , remove dots from Gmail
      .isEmail(),
    body('website')
      .trim()
      .isURL({ protocols: ['https'] }),
    body('age')
      .toInt(),                  // convert string to int
    body('tags')
      .optional()
      .isArray()
      .customSanitizer((value) => {
        // deduplicate and trim
        return [...new Set(value.map(t => t.trim().toLowerCase()))]
      })
  ],
  handler
)

validation error responses

Consistent error format is a contract with your frontend

const { validationResult } = require('express-validator')

// reusable validation middleware
const handleValidationErrors = (req , res , next) => {
  const errors = validationResult(req)
  if (!errors.isEmpty()) {
    const formatted = errors.array().map(e => ({
      field: e.path,
      value: e.value,
      message: e.msg,
      location: e.location  // body , params , query , headers
    }))
    return res.status(422).json({
      error: 'Validation failed',
      details: formatted
    })
  }
  next()
}

// usage
app.post('/users',
  [ body('email').isEmail() , body('password').isLength({ min: 8 }) ],
  handleValidationErrors,
  createUserHandler
)

security-focused validation patterns

// 1. Validate ALL input sources
app.put('/users/:id',
  [
    param('id').isInt({ min: 1 }).toInt(),
    query('include').optional().isIn(['profile' , 'orders' , 'all']),
    body('name').optional().trim().isLength({ min: 2 })
  ],
  handleValidationErrors,
  handler
)

// 2. Whitelist allowed fields - strip unknown
app.post('/profile',
  [
    body('name').exists(),
    body('bio').optional(),
    body('role').not().exists()  // prevent privilege escalation
  ],
  handler
)

// 3. Validate numeric ranges
body('amount')
  .isFloat({ min: 0.01 , max: 1000000 })

// 4. Validate string patterns
body('postalCode')
  .matches(/^\d{5}(-\d{4})?$/)

// 5. Prevent prototype pollution
body().custom((value) => {
  if (value.__proto__ || value.constructor?.prototype) {
    throw new Error('Prototype pollution attempt detected')
  }
  return true
})

prerequisites

express_12_security.md - Helmet , CORS , rate limiting , CSRF , SQL injection prevention


next → express_14_database.md