Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 66

Server-Side Validation for Data Integrity

Recommended resources

Hello! In the last lesson, you successfully implemented the DELETE endpoint, completing the full set of CRUD operations for your user API. Your API can now create, read, update, and delete data. However, there's a critical vulnerability: it blindly trusts any data sent to it.

This lesson addresses that gap. Our learning outcome is to add server-side input validation to ensure data integrity. We'll make our API more robust and secure by rejecting invalid or malicious data before it ever reaches our database.

Why Server-Side Validation is Crucial

You might think, "Can't I just add required or type='email' to my HTML form on the frontend?" While client-side validation is great for user experience, it offers zero security. A savvy user (or a malicious actor) can easily bypass your frontend and send requests directly to your API using tools like Postman or simple scripts.

Server-side validation is your API's last line of defense. It ensures that no matter where a request comes from, the data adheres to the rules you define.

Express JS #10 - Validation

To understand why this is so important, watch the first couple of minutes of this video by Anson. He explains perfectly why client-side validation is never enough.

Watch from the beginning (00:04) to 01:55. Focus on the core argument: your API doesn't know where the data is coming from and cannot trust the client.

As the video highlights, server-side validation is about protecting the integrity of your system—your database, your business logic, and the consistency of your data.

Input Validation Flowchart
This flowchart shows the role of input validation as a middleware step. The client's request is checked by the validation logic *before* it's allowed to proceed to the main application controller.

Introducing express-validator

To implement validation, we'll use express-validator, the most popular validation library for Express. It provides a set of middleware functions that you can chain together to create powerful validation rules.

First, let's install it in your project:

npm install express-validator

The core idea of express-validator revolves around three concepts:

  1. Validation Chains: You define a "chain" of rules for each input field (e.g., name must not be empty AND must be at least 3 characters long).
  2. Middleware: These chains are passed as middleware to your Express routes. They run before your main controller logic.
  3. Result Handling: You check the result of the validation in your controller. If there are errors, you stop execution and send a 400 Bad Request response.

Step 1: Implementing Validation for the createUser Endpoint

Let's apply validation to our POST /api/user route. We want to enforce two rules:

  • The name must be a non-empty string.
  • The email must be in a valid email format.

1. Add the Validation Middleware

In your src/routes/userRoutes.js file, import the body function from express-validator and add the validation chains to your POST route.

// src/routes/userRoutes.js
import express from 'express';
import {
  createUser,
  deleteUser,
  getAllUsers,
  getUserById,
  updateUser,
} from '../controllers/userController.js';
// Import the 'body' function
import { body } from 'express-validator';

const router = express.Router();

// ... other routes

router.post(
  '/user',
  // Add validation middleware here
  [
    body('name', 'Name is required').notEmpty(),
    body('email', 'A valid email is required').isEmail(),
  ],
  createUser
);

// ... other routes

export default router;

Let's break this down:

  • body('name', ...): We're targeting the name field in the request body. The second argument, 'Name is required', is a default error message.
  • .notEmpty(): This is a validator that checks if the field is not empty.
  • body('email', ...): We target the email field.
  • .isEmail(): This validator checks if the string is in a valid email format.
  • The array [...] groups our validation middleware. Express will run them in order.

2. Handle Validation Results in the Controller

The validation middleware doesn't automatically stop the request on failure. It just attaches the errors to the request object. It's our job to check for these errors in the createUser controller.

In src/controllers/userController.js, import validationResult and add the error-checking logic.

// src/controllers/userController.js

// Import validationResult
import { validationResult } from 'express-validator';
import {
  createUserService,
  // ... other services
} from '../models/user.js';
import handleResponse from '../utils/handleResponse.js';

export const createUser = async (req, res, next) => {
  // 1. Check for validation errors
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(400).json({ errors: errors.array() });
  }

  // 2. If valid, proceed with existing logic
  try {
    const { name, email } = req.body;
    const newUser = await createUserService({ name, email });
    handleResponse(res, 201, 'User created successfully', newUser);
  } catch (error) {
    next(error);
  }
};

// ... other controllers

The flow is simple:

  1. validationResult(req) gathers all validation errors.
  2. !errors.isEmpty() checks if the errors object contains any failures.
  3. If it does, we immediately return a 400 status with a JSON object containing an array of the errors. The request goes no further.
  4. If it's empty, we proceed to the try...catch block as before.

Now, try sending an invalid request to your POST /api/user endpoint, for example, with an empty body or an invalid email. You'll receive a 400 Bad Request response with specific error messages.

Server-Side Input Validation Error Example
This image shows an example of a `400 Bad Request` response from a server. The `errors` array clearly states that the provided email is not valid, which is exactly the kind of informative feedback we want our API to provide.

Step 2: Refining Validation with Sanitizers and Custom Messages

express-validator can do more than just validate; it can also sanitize inputs. A common sanitizer is trim(), which removes leading and trailing whitespace. This is great for preventing entries like " John Doe ".

You can also provide more specific error messages using the .withMessage() method. Let's improve our validation chain.

// src/routes/userRoutes.js

// ...

router.post(
  '/user',
  [
    body('name')
      .trim() // Sanitize first
      .notEmpty()
      .withMessage('Name cannot be empty.') // Custom message for notEmpty
      .isLength({ min: 3 })
      .withMessage('Name must be at least 3 characters long.'), // Custom message for isLength

    body('email')
      .isEmail()
      .withMessage('Please provide a valid email address.')
      .normalizeEmail(), // Sanitizer to convert email to a canonical form
  ],
  createUser
);

// ...

Here's what we added:

  • trim(): Removes whitespace from the name field.
  • isLength({ min: 3 }): Adds a length requirement.
  • withMessage(): Provides a specific message for each validation rule that fails.
  • normalizeEmail(): A powerful sanitizer that canonicalizes the email address (e.g., converts the domain to lowercase).
Test your understanding!

Now it's your turn. Apply similar validation rules to the PUT /api/user/:id route. You should validate the name and email fields in the request body. What changes do you need to make in userRoutes.js and the updateUser controller in userController.js?

Show answer
  1. In userRoutes.js: You would add the same validation middleware array to the router.put('/user/:id', ...) definition.

    // src/routes/userRoutes.js
    router.put(
      '/user/:id',
      [
        body('name')
          .trim()
          .notEmpty()
          .withMessage('Name cannot be empty.')
          .isLength({ min: 3 })
          .withMessage('Name must be at least 3 characters long.'),
        body('email')
          .isEmail()
          .withMessage('Please provide a valid email address.')
          .normalizeEmail(),
      ],
      updateUser
    );
    
  2. In userController.js: You would add the same validationResult check at the beginning of the updateUser function.

    // src/controllers/userController.js
    export const updateUser = async (req, res, next) => {
      const errors = validationResult(req);
      if (!errors.isEmpty()) {
        return res.status(400).json({ errors: errors.array() });
      }
    
      try {
        // ... existing logic
      } catch (error) {
        next(error);
      }
    };
    

Step 3: Organizing Validation with Schemas

As you can see, the validation array can get quite long. For complex applications, it's a best practice to extract this logic into a separate file. express-validator supports this with checkSchema.

  1. Create a validation schema file.
    Create a new directory src/utils and a file inside it named validationSchemas.js.

    // src/utils/validationSchemas.js
    export const createUserSchema = {
      name: {
        in: ['body'],
        trim: true,
        notEmpty: {
          errorMessage: 'Name cannot be empty.',
        },
        isLength: {
          options: { min: 3 },
          errorMessage: 'Name must be at least 3 characters long.',
        },
      },
      email: {
        in: ['body'],
        isEmail: {
          errorMessage: 'Please provide a valid email address.',
        },
        normalizeEmail: true,
      },
    };
    

    This object defines the same rules as before, but in a more structured and declarative way.

  2. Use checkSchema in your route.
    Now, update userRoutes.js to import and use this schema.

    // src/routes/userRoutes.js
    import express from 'express';
    // ...
    // Import checkSchema and the new schema object
    import { checkSchema } from 'express-validator';
    import { createUserSchema } from '../utils/validationSchemas.js';
    
    const router = express.Router();
    
    // ...
    
    router.post('/user', checkSchema(createUserSchema), createUser);
    
    // For the PUT route, you can reuse the same schema!
    router.put('/user/:id', checkSchema(createUserSchema), updateUser);
    
    // ...
    

    This makes your route definition much cleaner and your validation logic reusable.

Express JS #10 - Validation

This video provides a great walkthrough of moving validation rules into a schema object. It uses a slightly different syntax but the concept is identical.

Watch from 22:54 to 28:40. Pay attention to how a separate file is created for the schema and how checkSchema is used in the route handler to keep the code clean.


Step 4: Safely Using Validated Data with matchedData

There's one final, important step. After validation, you should only use the data that has been validated and sanitized. A request body might contain extra, unexpected fields. If you blindly use req.body, you might inadvertently use this untrusted data.

express-validator provides a helper function called matchedData that returns an object containing only the fields that passed validation.

Let's update the createUser controller to use it.

// src/controllers/userController.js
import { validationResult, matchedData } from 'express-validator';
// ...

export const createUser = async (req, res, next) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(400).json({ errors: errors.array() });
  }

  try {
    // Use matchedData to get a sanitized object of validated data
    const validatedData = matchedData(req);
    
    // Pass this clean data to your service
    const newUser = await createUserService(validatedData);
    
    handleResponse(res, 201, 'User created successfully', newUser);
  } catch (error) {
    next(error);
  }
};

By using matchedData(req) instead of req.body, you ensure that your service layer only ever receives data that your application expects and has explicitly validated. This is a key principle of secure coding.

Express JS #10 - Validation

The same video also covers matchedData very effectively. This short clip will show you exactly how and why to use it.

Watch from 20:33 to 22:54. Focus on the difference between using req.body and using the object returned by matchedData(req).


Conclusion

Congratulations! You have significantly hardened your API. By adding server-side validation, you've ensured that your application maintains data integrity and is protected against a common class of vulnerabilities.

Key Takeaways:

  • Server-side validation is mandatory for security and data integrity; client-side validation is only for user experience.
  • express-validator provides a middleware-based system for defining validation chains.
  • The standard flow is: define rules in the route, check for errors with validationResult in the controller, and return a 400 Bad Request if any errors exist.
  • Sanitizers like trim() and normalizeEmail() clean the data before it's used.
  • For clean code, organize complex rules into schemas and use checkSchema.
  • Always use matchedData() in your controller to work with a safe, validated subset of the request data.

Preview of the Next Lesson:
Your API is now complete and robust. But how do you confirm every part of it works as expected? In the next lesson, we will focus on how to test all API endpoints using a client tool like Postman or Thunder Client. We'll create a systematic testing suite to verify every success case, error case, and now, every validation failure case for your API.