Skip to content

Input Validation

Table of Contents


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

Helmet , CORS , rate limiting , CSRF , SQL injection prevention


next => express_14_database