Validating User Input in Express Authentication

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:

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:

Step 2: Devise a Plan

  1. Install and set up express-validator package
  2. Create a validation error handling middleware
  3. Define validation rules for login requests
  4. Define validation rules for signup requests
  5. Apply validation middleware to the authentication routes
  6. 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:

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:

  1. It extracts validation errors from the request using validationResult
  2. It checks if there are any validation errors
  3. If errors exist, it formats them into a structured object where each key is the field path and each value is the error message
  4. It creates a standardized Error object with details about the validation failure
  5. It passes this error to the next middleware (which will be our global error handler)
  6. 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:

  1. Specifies a field to validate
  2. Applies validation rules to that field
  3. Provides a custom error message if validation fails

For the login route, our validation is relatively simple:

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:

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:

Validation Chains

The express-validator package uses a chainable API for defining validation rules. This allows us to:

Centralized Error Handling

Our handleValidationErrors middleware centralizes error handling for all validation chains, which:

Progressive Validation

Our application now performs validation at multiple levels:

  1. Client-Side Validation: Should be implemented in frontend forms (not shown here)
  2. Route-Level Validation: Using express-validator (implemented in this phase)
  3. Model-Level Validation: Using Sequelize validators (implemented in previous phases)
  4. 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:

User-Friendly Error Messages

Error messages should help users correct their input:

Performance Considerations

Consider performance when designing validation:

Maintainable Validation Code

Structure validation code for maintainability:

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:

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:

Remember that validation is an ongoing concern. As your application evolves, review and update your validation rules to address new requirements and security considerations.