Skip to main content

🛡️ Request Validation and Sanitization

The most important rule in backend development has three words: never trust input. Every request that reaches your server is a promise the client might not keep. This lesson shows you how to check that incoming data is well-formed and safe before it touches your business logic — with clean, reusable validation middleware and the libraries that make it painless.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain why validation and sanitization are distinct, and why you need both
  • Write clear manual validation and recognize when it stops scaling
  • Build validation with express-validator using validation chains and schemas
  • Define a type-safe schema with Zod and turn it into reusable middleware
  • Defend against injection and XSS by sanitizing and using parameterized queries

Estimated Time: 45–60 minutes  •  Difficulty: Intermediate

Hands-on: Build a validated registration endpoint that rejects bad and malicious input.

In This Lesson

Why Never Trust Input

Your API is a public door. Anyone can knock — a legitimate frontend, a curious developer with curl, an automated scanner, or an attacker. The request body, query string, URL params, and headers are all attacker-controllable. Treating any of them as trustworthy is how applications get breached, corrupted, or crashed.

💡 Rule of thumb: "Never trust user input. Never. Not even if your user is another one of your own systems." A request that came from your React app today can be replayed with tampered data tomorrow.

Validating requests at the edge pays off in four ways:

  • Security — stop injection, XSS, and malformed payloads before they do damage.
  • Data integrity — keep garbage out of your database so the rest of the app can trust it.
  • Better UX — return precise, per-field errors the frontend can display.
  • Simpler code — downstream logic works with clean, predictable shapes instead of defensive checks everywhere.
Validation as a gate before business logic A request passes through a validation gate; valid data continues to business logic and the database, while invalid data is turned back with an error response. Request Validation gate Business logic → database 400 Error per-field detail valid invalid
Figure 1 — Validation is a gate that runs before your business logic. Valid data flows through; invalid data is turned back with a helpful error and never reaches the database.

Validation vs Sanitization

These two words are often used interchangeably, but they do different jobs — and a robust endpoint uses both.

📖 Two distinct steps

Validation asks a yes/no question: is this input acceptable? It rejects a bad email, a negative price, or a missing field. It does not change the data.

Sanitization transforms input into a safe, normalized form: trimming whitespace, lowercasing an email, escaping HTML, coercing "42" to the number 42. It cleans the data so it's safe to store and use.

What you typically check falls into three layers, from cheap to expensive:

LayerQuestionExample
Type & shapeRight data type? Required fields present?price is a number; email exists
Value constraintsWithin allowed ranges/patterns?name 3–100 chars; valid email format
Business rulesConsistent with app state?username is unique; category exists

💡 Check cheap things first

Order your checks so the fast ones run first. There's no point hitting the database to see if a username is taken if the username isn't even a valid string. Type checks reject the most requests for the least cost.

Manual Validation

The simplest approach is hand-written checks in the handler. It's worth seeing, because it makes the pain that libraries solve obvious.

app.post('/api/products', (req, res) => {
  const { name, price, categoryId } = req.body;
  const errors = [];

  if (!name || typeof name !== 'string') {
    errors.push('name is required and must be a string');
  } else if (name.length < 3 || name.length > 100) {
    errors.push('name must be between 3 and 100 characters');
  }

  if (typeof price !== 'number' || price <= 0) {
    errors.push('price must be a positive number');
  }

  if (!categoryId) {
    errors.push('categoryId is required');
  }

  if (errors.length > 0) {
    return res.status(400).json({ errors });
  }

  // ...only now is it safe to use the data
});

✅ Pros

No dependencies, full control, easy to read for one small endpoint.

❌ Cons

Verbose and repetitive; easy to forget a case; the validation logic tangles with the handler; and you'll copy-paste it across dozens of routes. This is exactly the point where a validation library earns its keep.

Using express-validator

express-validator wraps the battle-tested validator.js library as Express middleware. You describe each field with a fluent validation chain, and it collects the errors for you.

npm install express-validator
const express = require('express');
const { body, validationResult } = require('express-validator');

const app = express();
app.use(express.json());

app.post(
  '/api/products',
  [
    body('name')
      .isString().withMessage('name must be a string')
      .trim()
      .isLength({ min: 3, max: 100 }).withMessage('name must be 3–100 chars'),
    body('price')
      .isFloat({ gt: 0 }).withMessage('price must be greater than 0'),
    body('categoryId')
      .notEmpty().withMessage('categoryId is required'),
  ],
  (req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }
    // req.body is now validated (and trimmed)
    res.status(201).json({ data: req.body });
  }
);

A validation chain reads top-to-bottom: each call adds a rule, and .withMessage() customizes the error for the rule right before it. Common validators:

ValidatorPurposeExample
isString()Is a stringbody('name').isString()
isEmail()Valid email formatbody('email').isEmail()
isLength()String length rangebody('pw').isLength({ min: 8 })
isFloat()/isInt()Numeric rangebody('age').isInt({ min: 13 })
isIn()One of allowed valuesbody('role').isIn(['admin','user'])
custom()Your own logic (async ok)body('name').custom(fn)

Custom & async validators

Business rules that need the database go in custom(). Throwing an error (or returning a rejected promise) marks the field invalid:

body('username').custom(async (value) => {
  const existing = await User.findOne({ username: value });
  if (existing) {
    throw new Error('Username is already taken');
  }
  return true; // valid
});

A reusable validate() middleware

Checking validationResult in every handler is repetitive. Extract it once and every route stays clean:

// middleware/validate.js
const { validationResult } = require('express-validator');

const validate = (validations) => async (req, res, next) => {
  await Promise.all(validations.map((v) => v.run(req)));

  const errors = validationResult(req);
  if (errors.isEmpty()) return next();

  res.status(400).json({
    status: 'error',
    errors: errors.array().map((e) => ({ field: e.path, message: e.msg })),
  });
};

module.exports = validate;
// routes/product.routes.js
const validate = require('../middleware/validate');
const { body } = require('express-validator');

const productRules = [
  body('name').isString().trim().isLength({ min: 3, max: 100 }),
  body('price').isFloat({ gt: 0 }),
];

router.post('/', validate(productRules), controller.createProduct);
// the controller never has to think about validation again
flowchart LR A[Request] --> B["validate(rules)"] B -->|errors empty| C[Controller] B -->|errors found| D[400 with field details] C --> E[Response]

Schema Validation with Zod

For complex, nested payloads, defining a whole schema is cleaner than chaining rules field by field. Zod is the modern favorite: you declare the shape once, and it both validates and returns the parsed, correctly typed data. (Joi and Yup follow the same idea; Zod is shown here because it's TypeScript-native and widely adopted.)

npm install zod
const { z } = require('zod');

const userSchema = z.object({
  username: z.string().min(3).max(20),
  email: z.string().email(),
  password: z.string().min(8)
    .regex(/[A-Z]/, 'Needs an uppercase letter')
    .regex(/[a-z]/, 'Needs a lowercase letter')
    .regex(/\d/, 'Needs a number'),
  age: z.number().int().min(13).optional(),
});

Wrap it as generic middleware so any route can validate against any schema. On success, parse returns clean data (with defaults applied and unknown keys stripped); on failure it throws a structured error you can format:

// middleware/validateSchema.js
const validateSchema = (schema) => (req, res, next) => {
  const result = schema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({
      status: 'error',
      errors: result.error.issues.map((i) => ({
        field: i.path.join('.'),
        message: i.message,
      })),
    });
  }
  req.body = result.data; // parsed & typed
  next();
};

module.exports = validateSchema;
router.post('/register', validateSchema(userSchema), controller.register);

💡 Which library should I use?

All three do the job well. Reach for express-validator when you want middleware that's tightly integrated with Express and per-field chains. Reach for Zod (or Joi/Yup) when you have complex nested objects, want a single source of truth for the shape, or are on TypeScript and want inferred types for free. Many teams standardize on one and never look back.

Sanitization & Security

Validation confirms data is acceptable; sanitization makes it safe. express-validator ships sanitizers you chain right alongside validators:

SanitizerWhat it does
trim()Removes leading/trailing whitespace
normalizeEmail()Canonicalizes email (lowercases domain, etc.)
escape()Converts HTML characters like < to entities
toInt() / toFloat()Coerces strings to numbers
app.post('/api/register', validate([
  body('email').isEmail().withMessage('Invalid email')
    .normalizeEmail().trim(),
  body('name').isString().isLength({ min: 2, max: 50 })
    .trim().escape(),
  // Do NOT trim or escape passwords — it would silently alter them
  body('password').isLength({ min: 8 }),
]), controller.register);

The injection you must always prevent

Injection happens when untrusted input is concatenated into a command — SQL, a shell, or a NoSQL query. The fix is never to build the command from strings, but to pass data as parameters the driver escapes for you.

// ❌ BAD — user input becomes part of the SQL
const q = `SELECT * FROM users WHERE email = '${req.body.email}'`;

// ✅ GOOD — parameterized query; the driver handles escaping
const q = 'SELECT * FROM users WHERE email = ?';
connection.query(q, [req.body.email]);

NoSQL is vulnerable too. A raw { username: req.body.username } lets an attacker send an object like { "$ne": null } as the username. Enforce the type first:

const username =
  typeof req.body.username === 'string' ? req.body.username.trim() : '';
const query = { username }; // now guaranteed to be a plain string

⚠️ escape() is not a complete XSS defense

Escaping on input helps, but the real defense against cross-site scripting is output encoding — encoding data for the context where it's rendered (HTML body, attribute, JS). Modern frontend frameworks (React, Vue) escape by default. Treat input sanitization as one layer of defense, not the whole wall. The OWASP Input Validation Cheat Sheet is the reference to keep bookmarked.

📖 Validation is one layer, not the only one

Robust systems validate at multiple layers: the frontend for instant feedback, the API to guard against tampered or scripted requests, the service layer for business rules, and database constraints as the last line of defense. API validation never replaces database constraints — it complements them.

Hands-on Exercise

🏋️ A Validated Registration Endpoint

Objective: Build a POST /api/register route that accepts only clean, valid data.

Requirements:

  • username: 3–20 chars, letters/numbers/underscore only
  • email: valid email, normalized
  • password: 8+ chars with at least one uppercase, one lowercase, one number
  • age: optional; if present, must be 13 or older
  • acceptedTerms: must be boolean true

Return 400 with a list of { field, message } errors when anything fails; return 201 otherwise. Use your reusable validate() middleware.

💡 Hint

Use matches(/^[A-Za-z0-9_]+$/) for the username charset, normalizeEmail() to sanitize the email, and .equals('true') or a custom check for acceptedTerms. Chain three matches() rules on the password so each rule gets its own error message.

✅ Solution
const express = require('express');
const { body } = require('express-validator');
const validate = require('./middleware/validate');

const app = express();
app.use(express.json());

const registerRules = [
  body('username')
    .isString().trim()
    .isLength({ min: 3, max: 20 }).withMessage('username must be 3–20 chars')
    .matches(/^[A-Za-z0-9_]+$/).withMessage('letters, numbers, underscore only'),
  body('email')
    .isEmail().withMessage('invalid email')
    .normalizeEmail(),
  body('password')
    .isLength({ min: 8 }).withMessage('at least 8 characters')
    .matches(/[A-Z]/).withMessage('needs an uppercase letter')
    .matches(/[a-z]/).withMessage('needs a lowercase letter')
    .matches(/\d/).withMessage('needs a number'),
  body('age')
    .optional()
    .isInt({ min: 13 }).withMessage('must be 13 or older'),
  body('acceptedTerms')
    .equals('true').withMessage('you must accept the terms'),
];

app.post('/api/register', validate(registerRules), (req, res) => {
  // Data is validated & sanitized here
  res.status(201).json({ status: 'success', data: { user: req.body.username } });
});

app.listen(3000, () => console.log('http://localhost:3000'));

Best Practices

✅ Do

  • Validate on the server always — client validation is UX, not security.
  • Keep validation rules in their own module, separate from controllers.
  • Return specific, per-field error messages so the frontend can guide the user.
  • Sanitize (trim, normalize, coerce) as well as validate.
  • Use parameterized queries / an ORM to neutralize injection.

❌ Don't

  • Don't trust the frontend, other services, or "internal" callers.
  • Don't trim or escape passwords — you'd silently change what the user typed.
  • Don't lean on escape() alone for XSS — encode on output too.
  • Don't leak raw database or stack-trace errors back to the client.

Summary & Quiz

🎉 Key Takeaways

  • Never trust input — validate every request at the API boundary.
  • Validation checks acceptability; sanitization cleans and normalizes. Use both.
  • express-validator gives you fluent chains; Zod gives you reusable schemas — extract a validate() middleware either way.
  • Prevent injection with parameterized queries and strict type checks; XSS also needs output encoding.
  • API validation complements — never replaces — database constraints.

🎯 Quick Quiz

Question 1: What is the key difference between validation and sanitization?

Question 2: Which approach reliably prevents SQL injection?

Question 3: Why should you avoid running trim() or escape() on a password field?

📚 Further Reading

🚀 What's Next?

Now that only clean data gets through, we'll make your API's replies just as disciplined. Next up: response formatting and status codes — consistent envelopes and honest codes for every outcome.

🎉 Nicely defended!

Your endpoints now reject bad and malicious input before it can do harm. That's the single biggest security upgrade most APIs ever get.