Understanding Input Validation
Input validation is a critical aspect of web application security and user experience. When building authentication systems, it's especially important to validate user input thoroughly to prevent security vulnerabilities, data corruption, and poor user experiences.
In our authentication system, we need to validate user inputs such as email addresses, usernames, and passwords to ensure they meet our requirements and don't contain malicious data. Proper validation helps prevent issues like:
- SQL injection attacks
- Cross-site scripting (XSS) attacks
- Denial of service due to excessively large inputs
- Data inconsistency due to invalid formats
- Poor user experience due to unclear requirements
While we've implemented database-level constraints and model validations through Sequelize, we should also validate inputs at the route level before they reach the database. This provides more immediate feedback to the user and reduces unnecessary database operations.
Let's approach this systematically using George Polya's problem-solving method:
Step 1: Understand the Problem
We need to create validation middleware for our authentication routes that will:
- Check that required fields are present and not empty
- Validate the format of emails, usernames, and passwords
- Enforce length and content requirements
- Return helpful error messages when validation fails
- Allow valid requests to proceed to route handlers
Step 2: Devise a Plan
- Install and set up express-validator package
- Create a validation error handling middleware
- Define validation rules for login requests
- Define validation rules for signup requests
- Apply validation middleware to the authentication routes
- Test validation with valid and invalid inputs
Step 3: Execute the Plan
Let's start implementing our validation system:
Setting Up Express Validator
We'll use the express-validator package, which provides a set of middleware functions to validate and sanitize express requests. This package is built on top of validator.js, a powerful library for string validation.
Installing Express Validator
First, let's install the express-validator package:
npm install express-validator
This package will give us access to several validation functions, including:
check: Creates validation chains for request parametersbody: Shorthand for validating request body parametersparam: Shorthand for validating route parametersquery: Shorthand for validating query parametersvalidationResult: Extracts validation errors from a request
For our authentication routes, we'll primarily use check and validationResult.
Creating a Validation Error Handler
Now, let's create a middleware function that will handle validation errors. This middleware will check for validation errors and, if any are found, format them and return an appropriate response.
// File: backend/utils/validation.js
const { validationResult } = require('express-validator');
// Middleware for formatting errors from express-validator middleware
const handleValidationErrors = (req, _res, next) => {
// Extract validation errors from the request
const validationErrors = validationResult(req);
// If there are validation errors, format and return them
if (!validationErrors.isEmpty()) {
// Create an object to hold error messages keyed by field name
const errors = {};
// Format each error into the errors object
validationErrors
.array()
.forEach(error => errors[error.path] = error.msg);
// Create a formatted error object
const err = Error("Bad request.");
err.errors = errors;
err.status = 400;
err.title = "Bad request.";
// Pass the error to the global error handler
next(err);
}
// If there are no validation errors, proceed to the next middleware
next();
};
module.exports = {
handleValidationErrors
};
This handleValidationErrors middleware does several important things:
- It extracts validation errors from the request using validationResult
- It checks if there are any validation errors
- If errors exist, it formats them into a structured object where each key is the field path and each value is the error message
- It creates a standardized Error object with details about the validation failure
- It passes this error to the next middleware (which will be our global error handler)
- If no errors exist, it simply calls next() to proceed to the next middleware or route handler
This approach allows us to centralize error handling logic and maintain consistent error responses across our API.
Validating Login Requests
For login requests, we need to validate that the user has provided both a credential (username or email) and a password. Let's create validation middleware for the login route.
Login Validation Rules
Let's define the validation rules for login requests:
// File: backend/routes/api/session.js
// ... previous imports
const { check } = require('express-validator');
const { handleValidationErrors } = require('../../utils/validation');
// ... previous code
// Validation middleware for login requests
const validateLogin = [
// Validate that the credential field exists and is not empty
check('credential')
.exists({ checkFalsy: true })
.notEmpty()
.withMessage('Please provide a valid email or username.'),
// Validate that the password field exists and is not empty
check('password')
.exists({ checkFalsy: true })
.withMessage('Please provide a password.'),
// Apply the handleValidationErrors middleware to format and return any errors
handleValidationErrors
];
This validation middleware is an array of check middleware functions followed by our handleValidationErrors middleware. Each check function:
- Specifies a field to validate
- Applies validation rules to that field
- Provides a custom error message if validation fails
For the login route, our validation is relatively simple:
- We check that the credential (username or email) exists and is not empty
- We check that the password exists and is not empty
The exists({ checkFalsy: true }) option ensures that the field not only exists but also isn't an empty string, false, null, or undefined.
Applying Login Validation
Now, let's apply this validation middleware to our login route:
// File: backend/routes/api/session.js
// ... previous code
// Log in - with validation
router.post(
'/',
validateLogin, // Apply validation middleware before the route handler
async (req, res, next) => {
// The route handler code remains the same
const { credential, password } = req.body;
// ... rest of the login route handler
}
);
By adding the validateLogin middleware to our route, we ensure that requests will be validated before they reach our route handler. If validation fails, the handleValidationErrors middleware will format the errors and return an appropriate response.
Testing Login Validation
Let's test our login validation with invalid inputs:
// Missing credential
fetch('/api/csrf/restore')
.then(res => res.json())
.then(data => {
const csrfToken = data['XSRF-Token'];
return fetch('/api/session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'XSRF-TOKEN': csrfToken
},
body: JSON.stringify({
password: 'password123'
// credential is missing
})
});
})
.then(res => res.json())
.then(data => console.log('Missing credential response:', data));
// Empty password
fetch('/api/csrf/restore')
.then(res => res.json())
.then(data => {
const csrfToken = data['XSRF-Token'];
return fetch('/api/session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'XSRF-TOKEN': csrfToken
},
body: JSON.stringify({
credential: 'Demo-lition',
password: '' // Empty password
})
});
})
.then(res => res.json())
.then(data => console.log('Empty password response:', data));
You should see validation error responses for both of these tests, with appropriate error messages for the missing or empty fields.
Validating Signup Requests
For signup requests, we need more comprehensive validation to ensure that all required fields meet our format and content requirements. Let's create validation middleware for the signup route.
Signup Validation Rules
Let's define the validation rules for signup requests:
// File: backend/routes/api/users.js
// ... previous imports
const { check } = require('express-validator');
const { handleValidationErrors } = require('../../utils/validation');
// ... previous code
// Validation middleware for signup requests
const validateSignup = [
// Validate email format
check('email')
.exists({ checkFalsy: true })
.isEmail()
.withMessage('Please provide a valid email.'),
// Validate username length
check('username')
.exists({ checkFalsy: true })
.isLength({ min: 4 })
.withMessage('Please provide a username with at least 4 characters.'),
// Validate that username is not an email
check('username')
.not()
.isEmail()
.withMessage('Username cannot be an email.'),
// Validate password length
check('password')
.exists({ checkFalsy: true })
.isLength({ min: 6 })
.withMessage('Password must be 6 characters or more.'),
// Validate firstName exists
check('firstName')
.exists({ checkFalsy: true })
.withMessage('Please provide a first name.'),
// Validate lastName exists
check('lastName')
.exists({ checkFalsy: true })
.withMessage('Please provide a last name.'),
// Apply the handleValidationErrors middleware
handleValidationErrors
];
This validation middleware is more comprehensive than the login validation, with specific rules for each field:
- Email: Must exist and be in a valid email format
- Username: Must exist, be at least 4 characters long, and not be an email address
- Password: Must exist and be at least 6 characters long
- First Name: Must exist and not be empty
- Last Name: Must exist and not be empty
The isEmail() validator checks that the email field follows a valid email format, while the not().isEmail() validator ensures that the username doesn't look like an email address.
Applying Signup Validation
Now, let's apply this validation middleware to our signup route:
// File: backend/routes/api/users.js
// ... previous code
// Sign up - with validation
router.post(
'/',
validateSignup, // Apply validation middleware before the route handler
async (req, res) => {
// The route handler code remains the same
const { email, password, username, firstName, lastName } = req.body;
// ... rest of the signup route handler
}
);
Just like with the login route, adding the validateSignup middleware ensures that requests will be validated before they reach our route handler.
Testing Signup Validation
Let's test our signup validation with various invalid inputs:
// Invalid email format
fetch('/api/csrf/restore')
.then(res => res.json())
.then(data => {
const csrfToken = data['XSRF-Token'];
return fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'XSRF-TOKEN': csrfToken
},
body: JSON.stringify({
email: 'not-an-email', // Invalid email format
username: 'TestUser',
password: 'password123',
firstName: 'Test',
lastName: 'User'
})
});
})
.then(res => res.json())
.then(data => console.log('Invalid email response:', data));
// Short username
fetch('/api/csrf/restore')
.then(res => res.json())
.then(data => {
const csrfToken = data['XSRF-Token'];
return fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'XSRF-TOKEN': csrfToken
},
body: JSON.stringify({
email: 'test@example.com',
username: 'abc', // Too short
password: 'password123',
firstName: 'Test',
lastName: 'User'
})
});
})
.then(res => res.json())
.then(data => console.log('Short username response:', data));
// Username as email
fetch('/api/csrf/restore')
.then(res => res.json())
.then(data => {
const csrfToken = data['XSRF-Token'];
return fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'XSRF-TOKEN': csrfToken
},
body: JSON.stringify({
email: 'test@example.com',
username: 'test@username.com', // Username is an email
password: 'password123',
firstName: 'Test',
lastName: 'User'
})
});
})
.then(res => res.json())
.then(data => console.log('Username as email response:', data));
// Short password
fetch('/api/csrf/restore')
.then(res => res.json())
.then(data => {
const csrfToken = data['XSRF-Token'];
return fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'XSRF-TOKEN': csrfToken
},
body: JSON.stringify({
email: 'test@example.com',
username: 'TestUser',
password: '12345', // Too short
firstName: 'Test',
lastName: 'User'
})
});
})
.then(res => res.json())
.then(data => console.log('Short password response:', data));
// Missing firstName
fetch('/api/csrf/restore')
.then(res => res.json())
.then(data => {
const csrfToken = data['XSRF-Token'];
return fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'XSRF-TOKEN': csrfToken
},
body: JSON.stringify({
email: 'test@example.com',
username: 'TestUser',
password: 'password123',
// firstName is missing
lastName: 'User'
})
});
})
.then(res => res.json())
.then(data => console.log('Missing firstName response:', data));
Each of these tests should trigger a specific validation error, with a clear message about what went wrong and how to fix it.
Understanding Validation Patterns
Step 4: Review the Solution
Our implementation of input validation follows several important patterns and best practices:
Middleware-Based Validation
We've implemented validation as middleware that runs before our route handlers. This approach has several benefits:
- It separates validation logic from business logic
- It allows us to reuse validation rules across multiple routes
- It ensures consistent validation and error reporting
- It prevents invalid requests from reaching our route handlers
Validation Chains
The express-validator package uses a chainable API for defining validation rules. This allows us to:
- Apply multiple validation rules to a single field
- Customize error messages for each validation rule
- Combine existence checks with format validation
- Create complex validation logic with minimal code
Centralized Error Handling
Our handleValidationErrors middleware centralizes error handling for all validation chains, which:
- Ensures consistent error formatting
- Simplifies error management across routes
- Leverages our global error handling middleware
- Provides clear, field-specific error messages
Progressive Validation
Our application now performs validation at multiple levels:
- Client-Side Validation: Should be implemented in frontend forms (not shown here)
- Route-Level Validation: Using express-validator (implemented in this phase)
- Model-Level Validation: Using Sequelize validators (implemented in previous phases)
- Database-Level Constraints: Using database constraints (implemented in previous phases)
This multi-layered approach provides thorough protection against invalid or malicious inputs.
Advanced Validation Techniques
The basic validation we've implemented covers the essential requirements for our authentication routes, but express-validator offers many more advanced features for complex validation scenarios.
Custom Validators
For more complex validation requirements, you can create custom validators:
// Custom password strength validator
check('password')
.exists({ checkFalsy: true })
.isLength({ min: 8 })
.withMessage('Password must be at least 8 characters long')
.custom(value => {
// Check for at least one uppercase letter
if (!/[A-Z]/.test(value)) {
throw new Error('Password must contain at least one uppercase letter');
}
// Check for at least one lowercase letter
if (!/[a-z]/.test(value)) {
throw new Error('Password must contain at least one lowercase letter');
}
// Check for at least one number
if (!/[0-9]/.test(value)) {
throw new Error('Password must contain at least one number');
}
// Check for at least one special character
if (!/[^A-Za-z0-9]/.test(value)) {
throw new Error('Password must contain at least one special character');
}
return true;
})
This custom validator enforces a strong password policy by checking for specific character types.
Conditional Validation
Sometimes you need to apply validation rules conditionally based on other fields:
// Conditional validation for terms of service
check('tosAccepted')
.custom((value, { req }) => {
if (req.body.userType === 'business' && !value) {
throw new Error('Business users must accept the terms of service');
}
return true;
})
This validator only requires the terms of service to be accepted for business users.
Database-Dependent Validation
Some validation rules need to check the database, such as verifying that a username is unique:
// Check for unique username
check('username')
.custom(async (value) => {
const existingUser = await User.findOne({
where: { username: value }
});
if (existingUser) {
throw new Error('Username is already in use');
}
return true;
})
This custom validator performs a database query to check if the username is already taken.
Sanitization
Beyond validation, express-validator also provides sanitization functions to clean up input data:
// Sanitize and normalize email
check('email')
.isEmail()
.withMessage('Please provide a valid email address')
.normalizeEmail() // Normalize email format
.trim() // Remove leading/trailing whitespace
Sanitization helps standardize inputs and can prevent certain types of attacks.
Wildcards and Nesting
For more complex data structures, express-validator supports wildcards and nested fields:
// Validate an array of items
check('items.*.name')
.exists()
.withMessage('Each item must have a name')
// Validate nested fields
check('address.zipCode')
.isPostalCode('US')
.withMessage('Please provide a valid US ZIP code')
These features are particularly useful for more complex forms or API endpoints.
Validation Best Practices
While implementing validation, it's important to follow these best practices to ensure security, usability, and maintainability:
Security-Focused Validation
Always validate with security in mind:
- Input Length Limits: Restrict input lengths to prevent buffer overflow attacks and DoS
- Strict Type Checking: Validate that inputs are of the expected type
- Whitelist Validation: Allow only known good inputs rather than trying to block bad inputs
- Content Validation: Check that contents match expected patterns (e.g., email format)
User-Friendly Error Messages
Error messages should help users correct their input:
- Specific Guidance: Clearly explain what's wrong and how to fix it
- Positive Language: Focus on what's needed rather than what's wrong
- Field-Specific Messages: Target error messages to the specific field that failed validation
- Consistent Format: Use a consistent format for all error messages
Performance Considerations
Consider performance when designing validation:
- Early Returns: Check simple requirements first to avoid unnecessary processing
- Avoid Redundant Validation: Don't repeat the same validations at multiple levels
- Batch Database Queries: When validating against the database, try to batch queries
- Limit Complexity: Extremely complex validation can impact performance
Maintainable Validation Code
Structure validation code for maintainability:
- Centralized Definitions: Define common validation rules in a central location
- Reusable Validators: Create custom validators for common patterns
- Descriptive Variable Names: Use clear naming for validation middleware
- Documentation: Document validation rules for future reference
Conclusion
We have successfully implemented comprehensive input validation for our authentication routes using express-validator. This validation ensures that user inputs meet our requirements and helps prevent security vulnerabilities and data corruption.
Our validation implementation includes:
- Login Validation: Ensuring that users provide both a credential (username or email) and a password
- Signup Validation: Checking that new users provide valid email addresses, usernames, passwords, and names
- Error Handling: Providing clear, field-specific error messages when validation fails
- Middleware Integration: Applying validation before route handlers to keep business logic clean
This validation layer complements the model-level validations and database constraints we implemented in previous phases, creating a robust, multi-layered validation system.
With the addition of input validation, our authentication system is now more secure, user-friendly, and robust against invalid or malicious inputs.
Next Steps
To further enhance your application, consider:
- Implementing client-side validation in your frontend to provide immediate feedback
- Adding more advanced validation rules for enhanced security (e.g., password strength requirements)
- Implementing CAPTCHA or rate limiting to prevent brute force attacks
- Expanding validation to other routes beyond authentication
- Setting up automated testing for your validation rules
Remember that validation is an ongoing concern. As your application evolves, review and update your validation rules to address new requirements and security considerations.