Request Validation and Sanitization
Validating and sanitizing user input is critical for building secure and reliable APIs. Validation ensures data meets expected requirements, while sanitization cleans data to prevent security vulnerabilities like XSS and SQL injection.
Why Validation and Sanitization?
- Security: Prevent injection attacks (SQL, XSS, NoSQL)
- Data Integrity: Ensure data matches expected format and type
- User Experience: Provide clear error messages
- Business Logic: Enforce business rules and constraints
- Database Consistency: Prevent invalid data from entering database
Warning: Never trust user input. Always validate and sanitize data on the server-side, even if you have client-side validation.
Express-Validator
Express-validator is a popular validation and sanitization library built on validator.js:
# Install express-validator
npm install express-validator
Basic Validation Example
Let's validate user registration data:
const express = require('express');
const { body, validationResult } = require('express-validator');
const app = express();
app.use(express.json());
// POST /api/users - Create user with validation
app.post('/api/users',
// Validation rules
[
body('username')
.trim()
.isLength({ min: 3, max: 20 })
.withMessage('Username must be 3-20 characters')
.isAlphanumeric()
.withMessage('Username must contain only letters and numbers'),
body('email')
.trim()
.isEmail()
.withMessage('Invalid email address')
.normalizeEmail(),
body('password')
.isLength({ min: 8 })
.withMessage('Password must be at least 8 characters')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/)
.withMessage('Password must contain uppercase, lowercase, number, and special character'),
body('age')
.optional()
.isInt({ min: 13, max: 120 })
.withMessage('Age must be between 13 and 120'),
body('website')
.optional()
.isURL()
.withMessage('Invalid URL format')
],
// Handler
(req, res) => {
// Check validation results
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({
success: false,
message: 'Validation failed',
errors: errors.array()
});
}
// Validation passed, proceed with user creation
const { username, email, password, age, website } = req.body;
res.status(201).json({
success: true,
message: 'User created successfully',
data: { username, email }
});
}
);
app.listen(3000);
Common Validation Methods
Express-validator provides numerous validation methods:
// String validators
.isLength({ min: 5, max: 100 }) // Length check
.isEmpty() // Empty check
.isAlpha() // Letters only
.isAlphanumeric() // Letters and numbers
.isNumeric() // Numbers only
.isEmail() // Email format
.isURL() // URL format
.isIP() // IP address
.isJSON() // Valid JSON
.matches(/regex/) // Custom regex
// Number validators
.isInt({ min: 0, max: 100 }) // Integer range
.isFloat({ min: 0.0, max: 1.0 }) // Float range
.isDecimal() // Decimal number
// Boolean validators
.isBoolean() // Boolean value
// Date validators
.isDate() // Valid date
.isISO8601() // ISO date format
.isBefore('2025-12-31') // Date before
.isAfter('2020-01-01') // Date after
// Special validators
.isUUID() // UUID format
.isJWT() // JWT token
.isCreditCard() // Credit card number
.isPostalCode('US') // Postal code
.isMobilePhone('en-US') // Phone number
Sanitization Methods
Sanitization cleans and transforms input data:
// String sanitization
.trim() // Remove whitespace
.escape() // HTML escape (< > & ' " /)
.unescape() // HTML unescape
.toLowerCase() // Convert to lowercase
.toUpperCase() // Convert to uppercase
.normalizeEmail() // Normalize email (lowercase, remove dots from Gmail)
.blacklist('chars') // Remove blacklisted characters
.whitelist('chars') // Keep only whitelisted characters
// Number sanitization
.toInt() // Convert to integer
.toFloat() // Convert to float
.toBoolean() // Convert to boolean
// Date sanitization
.toDate() // Convert to Date object
// Custom sanitization
.customSanitizer(value => {
return value.replace(/[^a-zA-Z0-9]/g, '');
})
Tip: Chain sanitization before validation. For example, use .trim() before .isLength() to avoid counting whitespace.
Custom Validators
Create custom validation logic for specific requirements:
const { body, validationResult } = require('express-validator');
// Custom validator example
app.post('/api/users',
[
body('username')
.custom(async (value) => {
// Check if username already exists in database
const user = await User.findOne({ username: value });
if (user) {
throw new Error('Username already exists');
}
return true;
}),
body('password')
.custom((value, { req }) => {
// Access other fields using req
if (value === req.body.username) {
throw new Error('Password cannot be same as username');
}
return true;
}),
body('passwordConfirm')
.custom((value, { req }) => {
if (value !== req.body.password) {
throw new Error('Passwords do not match');
}
return true;
}),
body('birthDate')
.custom((value) => {
const age = calculateAge(value);
if (age < 18) {
throw new Error('Must be at least 18 years old');
}
return true;
})
],
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
// Proceed...
}
);
function calculateAge(birthDate) {
const today = new Date();
const birth = new Date(birthDate);
let age = today.getFullYear() - birth.getFullYear();
const monthDiff = today.getMonth() - birth.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
age--;
}
return age;
}
Validating Arrays and Nested Objects
Validate complex data structures:
const { body, validationResult } = require('express-validator');
// Validate array of objects
app.post('/api/orders',
[
// Validate array
body('items')
.isArray({ min: 1 })
.withMessage('Order must contain at least one item'),
// Validate each array element
body('items.*.productId')
.isInt()
.withMessage('Invalid product ID'),
body('items.*.quantity')
.isInt({ min: 1, max: 100 })
.withMessage('Quantity must be between 1 and 100'),
body('items.*.price')
.isFloat({ min: 0.01 })
.withMessage('Price must be positive'),
// Validate nested object
body('shippingAddress.street')
.trim()
.notEmpty()
.withMessage('Street is required'),
body('shippingAddress.city')
.trim()
.notEmpty()
.withMessage('City is required'),
body('shippingAddress.zipCode')
.isPostalCode('US')
.withMessage('Invalid ZIP code')
],
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
// Process order...
}
);
// Example request body:
// {
// "items": [
// { "productId": 1, "quantity": 2, "price": 29.99 },
// { "productId": 5, "quantity": 1, "price": 49.99 }
// ],
// "shippingAddress": {
// "street": "123 Main St",
// "city": "New York",
// "zipCode": "10001"
// }
// }
Validation with Joi
Joi is another popular validation library with a different approach:
# Install Joi
npm install joi
const Joi = require('joi');
// Define validation schema
const userSchema = Joi.object({
username: Joi.string()
.alphanum()
.min(3)
.max(20)
.required(),
email: Joi.string()
.email()
.required(),
password: Joi.string()
.min(8)
.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/)
.required()
.messages({
'string.pattern.base': 'Password must contain uppercase, lowercase, number, and special character'
}),
age: Joi.number()
.integer()
.min(13)
.max(120)
.optional(),
birthDate: Joi.date()
.max('now')
.required(),
website: Joi.string()
.uri()
.optional(),
role: Joi.string()
.valid('user', 'admin', 'moderator')
.default('user'),
preferences: Joi.object({
newsletter: Joi.boolean().default(false),
notifications: Joi.boolean().default(true)
}).optional()
});
// Middleware to validate with Joi
const validateUser = (req, res, next) => {
const { error, value } = userSchema.validate(req.body, {
abortEarly: false, // Return all errors, not just first
stripUnknown: true // Remove unknown fields
});
if (error) {
const errors = error.details.map(detail => ({
field: detail.path.join('.'),
message: detail.message
}));
return res.status(422).json({
success: false,
message: 'Validation failed',
errors
});
}
// Replace req.body with validated and sanitized data
req.body = value;
next();
};
// Use middleware
app.post('/api/users', validateUser, (req, res) => {
// req.body is now validated and sanitized
res.status(201).json({
success: true,
data: req.body
});
});
Note: Joi is more declarative and provides schema reusability, while express-validator integrates directly with Express routes. Choose based on your preference and project needs.
Creating Reusable Validation Middleware
Organize validation logic in reusable middleware:
// validators/userValidator.js
const { body, param, validationResult } = require('express-validator');
// Validation rules
const userValidationRules = () => {
return [
body('username')
.trim()
.isLength({ min: 3, max: 20 })
.withMessage('Username must be 3-20 characters')
.isAlphanumeric()
.withMessage('Username must be alphanumeric'),
body('email')
.trim()
.isEmail()
.withMessage('Invalid email')
.normalizeEmail(),
body('password')
.isLength({ min: 8 })
.withMessage('Password must be at least 8 characters')
];
};
const updateUserValidationRules = () => {
return [
param('id')
.isInt()
.withMessage('Invalid user ID'),
body('username')
.optional()
.trim()
.isLength({ min: 3, max: 20 })
.isAlphanumeric(),
body('email')
.optional()
.trim()
.isEmail()
.normalizeEmail()
];
};
// Middleware to check validation results
const validate = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({
success: false,
errors: errors.array()
});
}
next();
};
module.exports = {
userValidationRules,
updateUserValidationRules,
validate
};
// routes/users.js
const express = require('express');
const router = express.Router();
const { userValidationRules, updateUserValidationRules, validate } = require('../validators/userValidator');
router.post('/', userValidationRules(), validate, createUser);
router.put('/:id', updateUserValidationRules(), validate, updateUser);
module.exports = router;
Sanitization Best Practices
// Always sanitize user input
app.post('/api/posts',
[
body('title')
.trim() // Remove whitespace
.escape() // Prevent XSS
.notEmpty()
.withMessage('Title is required'),
body('content')
.trim()
.escape() // Prevent XSS
.isLength({ min: 10 })
.withMessage('Content must be at least 10 characters'),
body('tags')
.optional()
.customSanitizer(value => {
// Clean and normalize tags array
if (Array.isArray(value)) {
return value
.map(tag => tag.trim().toLowerCase())
.filter(tag => tag.length > 0)
.slice(0, 5); // Limit to 5 tags
}
return [];
})
],
validate,
createPost
);
Security Warning: Always escape HTML content unless you explicitly need to allow HTML and have implemented proper HTML sanitization using libraries like DOMPurify or sanitize-html.
File Upload Validation
Validating file uploads requires special handling:
const multer = require('multer');
const path = require('path');
// Configure multer
const storage = multer.diskStorage({
destination: './uploads/',
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname));
}
});
const fileFilter = (req, file, cb) => {
// Accept images only
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Invalid file type. Only JPEG, PNG, and GIF allowed'), false);
}
};
const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 5 * 1024 * 1024 // 5MB max
}
});
// Upload endpoint with validation
app.post('/api/upload',
upload.single('avatar'),
[
body('description')
.optional()
.trim()
.isLength({ max: 200 })
.withMessage('Description must be max 200 characters')
],
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
if (!req.file) {
return res.status(400).json({ message: 'No file uploaded' });
}
res.json({
success: true,
file: req.file,
description: req.body.description
});
}
);
Exercise: Create a product API with comprehensive validation:
- POST /api/products - Create product with validation for: name (3-100 chars), description (10-500 chars), price (positive float), category (enum: Electronics, Clothing, Food, Books), stock (non-negative integer), images (array of URLs), metadata (nested object)
- Implement custom validator to check if category exists
- Sanitize all string inputs (trim, escape)
- Create reusable validation middleware
- Return user-friendly error messages
- Test with valid and invalid data
Validation Error Formatting
Format validation errors for better user experience:
const formatValidationErrors = (errors) => {
return errors.array().reduce((acc, error) => {
// Group errors by field
if (!acc[error.path]) {
acc[error.path] = [];
}
acc[error.path].push(error.msg);
return acc;
}, {});
};
app.post('/api/users', userValidationRules(), (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({
success: false,
message: 'Validation failed',
errors: formatValidationErrors(errors)
});
}
// Proceed...
});
// Error response format:
// {
// "success": false,
// "message": "Validation failed",
// "errors": {
// "username": ["Username must be 3-20 characters", "Username must be alphanumeric"],
// "email": ["Invalid email address"],
// "password": ["Password must be at least 8 characters"]
// }
// }
In the next lesson, we'll learn how to implement comprehensive error handling in Express applications to gracefully handle validation errors, server errors, and custom error scenarios.