Validation and Input Handling

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Express 5.3 (npm express 5.3.0, published 2026-10-09) on Node.js 18 or later, the minimum for Express 5, and it states where Express 4 (latest 4.22.3) behaves differently. The validation libraries are the current majors at the time of writing: Zod 4.6, express-validator 7.3, Joi 18.3 and Ajv 8.20. Everything was checked against the official documentation, the package metadata and running code on 2026-10-29. Express itself validates nothing: it parses bytes into req.body, req.query, req.params and req.headers and hands them to your code, so every guarantee about the shape and the size of that data has to be added by the application. The page explains what can arrive, shows four ways to enforce a contract (schema libraries as middleware), then covers the attacks that validation alone does not stop: mass assignment, oversized input and the injection families. All examples are ESM, use the node: prefix for built-in modules, belong to the Bookshelf scenario used across this section, and contain placeholders only, never real secrets. Parsing is covered in Body Parsing, the request object in Request, how failures become responses in Error Handling, and the wider hardening checklist in Security Best Practices.

Everything that arrives is untrusted

A request is written by whoever opens the socket. The browser form, the single-page application and the mobile client are only the polite clients; curl, a script and an attacker send exactly the bytes they want. Two rules follow, and the rest of the page applies them.

  1. Validate on the server, every time, whatever the client already checked. Client-side checks improve the user experience and nothing else.

  2. Validate at the edge, once, against a declared contract, and let the rest of the code work with the parsed result. A handler that receives a value which has passed z.uuid() does not need to wonder whether it is a string.

Each input has its own type surface, which is what a schema must pin down:

Input What the type can really be Typical defence

req.body (JSON)

Any JSON value: object, array, string, number, boolean, null; or undefined when no parser ran or the Content-Type did not match (Express 5). Numbers may be floats, huge or negative; strings may be megabytes long.

Parser limit, then a strict object schema with typed, bounded fields.

req.body (form)

Strings, and arrays of strings for repeated keys; with extended: true also nested objects.

Same schema, with explicit conversion of numbers and booleans.

req.query

Strings, arrays of strings for repeated keys, and with the extended query parser nested objects. Never numbers or booleans.

A schema that rejects arrays and objects where a string is expected, with explicit conversion.

req.params

Strings (arrays of segments for a wildcard parameter in Express 5), already percent-decoded by Express. They can be very long and can contain decoded characters such as /, .. or a NUL.

Constrain the parameter in the route and in a schema (z.uuid(), an integer pattern).

req.headers, req.get()

Lower-cased names; strings, or an array only for set-cookie. Node joins repeated headers with a comma and keeps the first of some singleton headers. Anything a client sends, including Host, X-Forwarded-For and Origin.

Validate custom headers; never trust forwarding headers unless trust proxy says a proxy wrote them.

req.cookies, req.signedCookies

Strings from cookie-parser, or false for a failed signature; a cookie can also contain a JSON-looking string.

Treat like any other string; verify the signature or use a server-side session.

req.files, req.file

The multipart name, original file name and declared MIME type are client claims.

See File Uploads and Downloads.

Validation answers one question: is this value inside the set the application is prepared to handle? It is not the same as sanitising (changing the value so that it becomes acceptable) or encoding (making the value safe for one destination such as SQL or HTML). The sections below keep the three apart, because mixing them is the source of most bugs: validation belongs at the edge, parameterisation and encoding belong where the data is used.

What Express 5 changes for input handling

Several Express 5 changes alter how validation middleware must be written. The first two break tutorials that call validators directly on the request.

Express 5 behaviour Consequence for validation code

req.query is a getter on the request, computed from the URL on access. Assigning to it throws TypeError: Cannot set property query of #<IncomingMessage> which has only a getter (verified on 5.3.0).

Middleware that replaces or edits req.query in place (sanitisers written for Express 4) fails or does nothing. Store the parsed values elsewhere, such as res.locals, or a property you define yourself. Check that any third-party sanitising middleware states Express 5 support before adopting it.

The default query parser is simple, which uses node:querystring. a[b]=1 is the literal key a[b], tag=x&tag=y is an array and nothing nests.

req.query is flat: values are string or string[]. Express 4 defaulted to extended (the qs library), where the same input produced nested objects. Keep simple unless a feature needs nesting.

With app.set('query parser', 'extended') (or express.urlencoded({ extended: true })) Express uses qs with allowPrototypes: true.

Keys such as toString and constructor[prototype][y] are kept as ordinary properties (verified), so req.query.toString can be a string and break code that calls it. Do not call methods on parsed input; validate into a fresh object.

req.body is undefined unless a body parser ran and matched the Content-Type. In Express 4 body-parser set it to {} when it ran and the type did not match.

Destructuring const { title } = req.body throws a TypeError for a request without a body or with the wrong content type. A schema that expects an object rejects undefined cleanly (Joi needs .required() on the root schema).

express.urlencoded() defaults to extended: false (body-parser 2).

Form fields are flat: strings or arrays of strings.

req.param(name) was removed.

It searched params, body and query in turn, which made source confusion possible. Read the specific source.

req.params for wildcards is an array of segments (/*splat), not a string.

Validate with an array schema, or join and check the result. See Route Paths and Parameters.

Where validation sits in the pipeline

Validation is a layer between parsing and business logic. It runs after the body parser (it needs req.body) and after authentication when the schema or the rules depend on who is calling, and it must run before any code that reads the input. Rejecting early keeps database connections, file handles and CPU out of the reach of garbage.

flowchart LR A["Request"] --> B["Body parser with a size limit"] B --> C["Authenticate"] C --> D["Validate: params, query, headers, body"] D -->|"valid"| E["Handler works with parsed values"] D -->|"invalid"| F["next(ValidationError)"] E --> G["Serialise only an allow-listed response"] F --> H["Error handler: 422 problem details"] B -->|"too large or malformed"| H

Three placement rules keep the layer honest:

  • Validate per route, next to the handler (app.post('/books', validate(...), handler)), so the contract is visible where the route is declared. A global validator cannot know the schema.

  • Validate every location the handler reads. A route that checks req.body and then trusts req.query.sort has validated half its inputs.

  • Hand the handler the parsed result, not the raw request. The raw req.body still contains whatever extra fields the client sent; the parsed object contains only what the schema allows.

Choosing a validation library

Four libraries cover almost every Express application. They differ in how the contract is written, not in what they can enforce.

Library Contract style Strengths Watch for

Zod 4 (zod)

TypeScript-first schema objects built from code, with z.infer for static types.

One definition gives validation, transformation and a TypeScript type; strict objects, unions, refinements; works in the browser too; can emit JSON Schema.

Coercion (z.coerce) is permissive; unknown keys are stripped by default unless z.strictObject is used.

express-validator 7 (express-validator)

Rules chained per field (body('email').isEmail()), executed as middleware; results read with validationResult().

Express-aware: knows body, query, params, headers and cookies; built-in sanitisers; checkExact() rejects unknown fields; wraps validator.js.

Validators run on strings and on each element of an array; field-by-field rules get long; not type-safe by itself.

Joi 18 (joi)

Declarative schema objects (Joi.object({...})), mature and expressive.

Rich rule set, conditional logic (when), good messages; unknown keys are errors by default.

Requires Node.js 20 or later; convert is on by default; undefined input passes unless the schema is .required().

Ajv 8 (ajv, plus ajv-formats)

JSON Schema (drafts 7, 2019-09 and 2020-12), compiled to fast validation functions.

A standard format shared with OpenAPI and other languages; very fast; strict mode catches schema mistakes.

Coercion and defaults mutate the data in place; allErrors: true and expensive keywords need care on hostile input.

Choose Zod when the application is TypeScript (see TypeScript with Express) or when one source of truth for types and runtime checks matters; express-validator when the team prefers rule chains and wants sanitisers close to the route; Joi when it is already in the codebase; Ajv when the contract is already an OpenAPI document or JSON Schema (see Building REST APIs). Whatever the choice, build the same three things: a middleware that validates, a single error type, and one error response shape.

Shared plumbing: one error type and one response

The four implementations below share a ValidationError and an error handler, so that clients see the same response whichever library produced the failure. The Zod middleware also lives here. The error carries a list of { location, path, message } entries and travels the normal Express error path, so it reaches the central handler described in Error Handling. The status is 422 (Unprocessable Content), which says "the syntax was fine, the content is not acceptable"; 400 is equally defensible and is what many APIs use. Pick one and apply it everywhere. Malformed JSON and oversized bodies are produced earlier, by the body parser, with 400 and 413.

// validate.mjs
import { STATUS_CODES } from 'node:http';

/** A request that failed validation. The error handler turns it into a 422 problem document. */
export class ValidationError extends Error {
  status = 422;
  expose = true;

  constructor(errors) {
    super('The request is not valid');
    this.name = 'ValidationError';
    this.errors = errors;
  }
}

/** Renders ValidationError as application/problem+json; everything else goes to the next handler. */
export function validationErrorHandler(err, req, res, next) {
  if (!(err instanceof ValidationError)) {
    return next(err);
  }
  res
    .status(err.status)
    .type('application/problem+json')
    .json({
      type: 'about:blank',
      title: STATUS_CODES[err.status],
      status: err.status,
      detail: err.message,
      errors: err.errors,
    });
}

/**
 * Zod validation middleware. Every location is validated, all failures are reported together,
 * and the parsed (stripped, coerced, defaulted) values go to res.locals.valid.
 */
export function validate(schemas) {
  return (req, res, next) => {
    const valid = {};
    const errors = [];

    for (const location of ['params', 'query', 'headers', 'body']) {
      const schema = schemas[location];
      if (!schema) {
        continue;
      }
      const result = schema.safeParse(req[location]);
      if (result.success) {
        valid[location] = result.data;
      } else {
        for (const issue of result.error.issues) {
          errors.push({ location, path: issue.path.join('.'), code: issue.code, message: issue.message });
        }
      }
    }

    if (errors.length > 0) {
      return next(new ValidationError(errors));
    }
    res.locals.valid = valid;
    next();
  };
}

Three details of this middleware are deliberate. It validates every location before it fails, so the client sees all problems in one response. It writes the parsed values to res.locals.valid, because req.query cannot be assigned in Express 5 and req.body should keep the raw bytes for logging or signature checks. And it reads the location as req[location], which includes req.headers: Zod’s default object schema ignores (and strips) the many headers it does not mention, which is what you want for a header contract.

Zod 4

Zod schemas are ordinary JavaScript values. The Bookshelf contract splits into one schema per operation: create, patch, list and identifier.

// book-schemas.mjs
import { z } from 'zod';

// Query values are strings. Number() would also accept '0x10', '1e3' and even ['5'], so gate it with a pattern first.
const queryInt = (min, max) =>
  z.string().regex(/^\d{1,9}$/, 'Expected a non-negative integer').transform(Number).pipe(z.int().min(min).max(max));

export const bookId = z.object({ id: z.uuid() });

export const createBook = z.strictObject({
  title: z.string().trim().min(1).max(200),
  isbn: z.string().regex(/^\d{10}(\d{3})?$/, 'Expected an ISBN-10 or ISBN-13 without hyphens'),
  authorIds: z.array(z.uuid()).min(1).max(10),
  priceCents: z.int().min(0).max(1_000_000),
  publishedOn: z.iso.date().optional(),
  tags: z.array(z.string().trim().min(1).max(30)).max(10).default([]),
});

// PATCH accepts any subset of the creatable fields, and still rejects everything else.
export const updateBook = createBook.partial().refine((value) => Object.keys(value).length > 0, {
  message: 'Send at least one field to change',
});

export const listBooks = z.object({
  q: z.string().trim().max(100).optional(),
  page: queryInt(1, 10_000).default(1),
  pageSize: queryInt(1, 100).default(20),
  sort: z.enum(['title', '-title', 'publishedOn', '-publishedOn']).default('title'),
});

export const idempotencyHeaders = z.object({
  'idempotency-key': z.uuid().optional(),
});

The router attaches the schemas to the routes. Handlers read only res.locals.valid, and the stored object is built from parsed fields plus values the server generates.

// app.mjs
import { randomUUID } from 'node:crypto';
import express from 'express';
import { validate, validationErrorHandler } from './validate.mjs';
import { bookId, createBook, idempotencyHeaders, listBooks, updateBook } from './book-schemas.mjs';

export function createApp() {
  const app = express();
  const books = new Map();

  app.use(express.json({ limit: '10kb' }));

  app.get('/api/v1/books', validate({ query: listBooks }), (req, res) => {
    const { q, page, pageSize, sort } = res.locals.valid.query;
    const descending = sort.startsWith('-');
    const field = descending ? sort.slice(1) : sort;
    const items = [...books.values()]
      .filter((book) => !q || book.title.toLowerCase().includes(q.toLowerCase()))
      .sort((a, b) => String(a[field] ?? '').localeCompare(String(b[field] ?? '')) * (descending ? -1 : 1));
    res.json({ page, pageSize, total: items.length, items: items.slice((page - 1) * pageSize, page * pageSize) });
  });

  app.post('/api/v1/books', validate({ headers: idempotencyHeaders, body: createBook }), (req, res) => {
    const book = { id: randomUUID(), ...res.locals.valid.body, createdAt: new Date().toISOString() };
    books.set(book.id, book);
    res.status(201).location(`/api/v1/books/${book.id}`).json(book);
  });

  app.patch('/api/v1/books/:id', validate({ params: bookId, body: updateBook }), (req, res) => {
    const book = books.get(res.locals.valid.params.id);
    if (!book) {
      return res.status(404).type('application/problem+json').json({ title: 'Not Found', status: 404 });
    }
    Object.assign(book, res.locals.valid.body);
    res.json(book);
  });

  app.use(validationErrorHandler);
  return app;
}

Install the packages and start the application with a small entry file. The other examples in this page replace zod by the library they use and export createApp() in the same way.

npm install express zod
// server.mjs
import { createApp } from './app.mjs';

createApp().listen(3000, (error) => {
  if (error) {
    throw error;
  }
  console.log('Bookshelf listening on http://localhost:3000');
});

The behaviour that matters, each point checked against the code above:

  • z.strictObject({...}) fails on unknown keys with the code unrecognized_keys. z.object({...}) silently drops them. Both stop the unknown field from reaching the database; the strict form also tells the client it sent something wrong, which surfaces typos and mass-assignment attempts. In Zod 4 .strict() still works but z.strictObject() is the documented form.

  • z.int() accepts only safe integers, so 1.5 fails. z.number() accepts any finite float.

  • .trim() is a transformation: the parsed value is trimmed (' Dune ' becomes Dune), the input is not mutated.

  • .default([]) fills in a missing key. In Zod 4 a default is returned as the output value without being parsed again, so it must already have the output type.

  • z.email(), z.uuid() and z.iso.date() are top-level format schemas in Zod 4. The Zod 3 forms z.string().email() and z.string().uuid() still work but are deprecated.

  • .partial() makes every field optional and .refine() adds a check over the whole object. Together they express "a PATCH body with at least one known field".

  • safeParse() returns a result instead of throwing; use it in middleware so an invalid request never becomes an exception. parse() throws a ZodError, which you would otherwise have to translate.

  • Error details are in error.issues (each with path, code and message). z.flattenError(error) groups messages by field for forms, z.treeifyError(error) keeps nested structure, and z.prettifyError(error) renders text for logs. The Zod 3 methods .format() and .flatten() are deprecated.

  • z.infer<typeof createBook> gives the TypeScript type of the parsed value, so a typed handler needs no manual interface. See TypeScript with Express.

Discriminated unions model request bodies that vary by a type field, and .transform() plus .pipe() build a conversion followed by a check, which the next section uses for query strings.

Type coercion and the string problem

Query strings, path parameters and form fields are text. Turning text into a number or a boolean is a conversion, and the built-in converters are more generous than most people expect. Number(), which z.coerce.number() uses, was checked against the values below:

Input z.coerce.number().int() result Why it matters

'10'

10

Intended.

'1e3'

1000

Exponent notation is accepted; a pageSize of 1e3 is probably not what was meant.

'0x10'

16

Hexadecimal is accepted.

['5']

5

A one-element array is converted through its string form, so ?page=5 and ?page[]=5 are indistinguishable.

true

1

Possible in a JSON body.

'', ' ', '12abc', 'Infinity', null

rejected in Zod 4

Older tutorials rely on Number('') being 0; Zod 4 rejects it, but a hand-written Number(value) does not.

The same trap applies to booleans: z.coerce.boolean() turns every non-empty string into true, including 'false' and '0'. Zod 4 adds z.stringbool(), which understands 'true', 'false', '1', '0', 'yes', 'no' and similar words and rejects the rest. The queryInt helper in the Bookshelf schemas shows the safer pattern for integers: check the text with a pattern, convert with Number, then validate the number. Other libraries have the same choice. Joi converts by default (convert: true) and offers .strict() per field; Ajv converts only with coerceTypes; express-validator converts only when you add .toInt() or .toBoolean(), and toBoolean() is lenient by default.

Two more conversion habits are worth abandoning. Do not coerce JSON request bodies: the type in a JSON document is information, and a string where a number was required is a client bug worth reporting. And do not rely on a database driver to convert; Mongoose casts a numeric string to a number for a Number path, which can hide the very error the schema should report.

express-validator 7

express-validator is a set of chain builders that become middleware. The chains attach to the route, record their failures on the request, and a final middleware turns the recorded failures into a response.

// express-validator-books.mjs
import express from 'express';
import { body, checkExact, header, matchedData, param, query, validationResult } from 'express-validator';
import { ValidationError, validationErrorHandler } from './validate.mjs';

const uuid = (chain) => chain.isString().bail().isUUID();

export const createBookRules = [
  header('idempotency-key').optional().isUUID(),
  body('title').isString().withMessage('title must be a string').bail().trim().isLength({ min: 1, max: 200 }),
  body('isbn').isString().bail().matches(/^\d{10}(\d{3})?$/).withMessage('Expected an ISBN-10 or ISBN-13'),
  body('authorIds').isArray({ min: 1, max: 10 }),
  body('authorIds.*').custom((value) => typeof value === 'string').bail().isUUID(),
  body('priceCents').isInt({ min: 0, max: 1_000_000, allow_leading_zeroes: false }).bail().toInt(),
  body('publishedOn').optional().isISO8601({ strict: true, strictSeparator: true }),
  body('tags').optional().isArray({ max: 10 }),
  body('tags.*').isString().bail().trim().isLength({ min: 1, max: 30 }),
  // Reject any body field that no rule above mentions.
  checkExact([], { locations: ['body'], message: 'Unknown field' }),
];

export const listBooksRules = [
  query('q').optional().isString().bail().trim().isLength({ max: 100 }),
  query('page').optional().isInt({ min: 1, max: 10_000 }).toInt(),
  query('pageSize').optional().isInt({ min: 1, max: 100 }).toInt(),
  query('sort').optional().isIn(['title', '-title', 'publishedOn', '-publishedOn']),
];

export const bookIdRules = [param('id').custom((v) => typeof v === 'string').bail().isUUID()];

/** Collects the rule failures into one ValidationError, or exposes the sanitized values. */
export function checkRules(req, res, next) {
  const result = validationResult(req);
  if (!result.isEmpty()) {
    return next(
      new ValidationError(
        result.array().map((error) =>
          error.type === 'field'
            ? { location: error.location, path: error.path, message: error.msg }
            : { location: 'body', path: (error.fields ?? []).map((f) => f.path).join(','), message: error.msg },
        ),
      ),
    );
  }
  // matchedData returns only fields that have a rule, so nothing else reaches the handler.
  res.locals.valid = matchedData(req, { includeOptionals: false });
  next();
}

export function createApp() {
  const app = express();
  app.use(express.json({ limit: '10kb' }));
  app.post('/api/v1/books', createBookRules, checkRules, (req, res) => res.status(201).json(res.locals.valid));
  app.get('/api/v1/books', listBooksRules, checkRules, (req, res) => res.json(res.locals.valid));
  app.use(validationErrorHandler);
  return app;
}

Points specific to this library, all verified with the example:

  • Chains run in order and collect every failure unless .bail() stops the chain at the first one. Start a field with a type guard (isString().bail()) so that later validators never see an array or an object. Without it, a standard validator applied to an array validates each element, so ?q=a&q=b can pass a rule written for one string.

  • Standard validators operate on the string form of the value. isInt() accepts both 12 and '12', and .toInt() is what changes the stored value to a number; a JSON body that sends '12' still passes. Add a .custom() check on typeof when that is not acceptable.

  • Wildcards select array items and object properties: body('authorIds.*') applies the rule to each element and the error path becomes authorIds[0].

  • matchedData(req) returns only fields that have a rule (includeOptionals: false drops absent optional fields), and sanitisers have already been applied. It is the allow-list. Reading req.body directly bypasses it.

  • checkExact() adds an unknown_fields error for any field with no rule, the equivalent of z.strictObject. It must come after the rules of the request; called with an empty list, as in the example, it considers the chains that already ran on the request.

  • validationResult(req) is evaluated lazily. In version 7 each field error is { type: 'field', value, msg, path, location }; param of version 6 was renamed path. Other error types (alternative, alternative_grouped, unknown_fields) come from oneOf() and checkExact(), so handle type.

  • checkSchema() takes the same rules as an object, which reads better for long contracts, and .run(req) executes a chain imperatively inside your own middleware.

  • .escape() and .trim() are sanitisers that rewrite the stored value. HTML-escaping on input stores data that is wrong for every destination except HTML; prefer encoding at output (see HTML and cross-site scripting).

  • Since version 7 the express-validator/check and express-validator/filter entry points and the sanitize* functions are gone: import body, query, param, header and cookie from express-validator and use the chain methods for sanitising.

Joi 18

Joi describes the contract as a schema and validates a value against it with schema.validate(value, options), which returns { error, value }. Joi 18 needs Node.js 20 or later.

// joi-books.mjs
import express from 'express';
import Joi from 'joi';
import { ValidationError, validationErrorHandler } from './validate.mjs';

const createBook = Joi.object({
  title: Joi.string().trim().min(1).max(200).required(),
  isbn: Joi.string().pattern(/^\d{10}(\d{3})?$/).required(),
  authorIds: Joi.array().items(Joi.string().guid({ version: ['uuidv4', 'uuidv7'] })).min(1).max(10).required(),
  priceCents: Joi.number().strict().integer().min(0).max(1_000_000).required(),
  publishedOn: Joi.string().isoDate(),
  tags: Joi.array().items(Joi.string().trim().min(1).max(30)).max(10).default([]),
}).required(); // without .required(), an undefined req.body would pass validation

const listBooks = Joi.object({
  q: Joi.string().trim().max(100),
  page: Joi.number().integer().min(1).max(10_000).default(1),
  pageSize: Joi.number().integer().min(1).max(100).default(20),
  sort: Joi.string().valid('title', '-title', 'publishedOn', '-publishedOn').default('title'),
});

/** Joi middleware: abortEarly false reports every problem; unknown keys are errors (the default). */
export function validateJoi(schemas) {
  return (req, res, next) => {
    const valid = {};
    const errors = [];

    for (const location of ['params', 'query', 'body']) {
      const schema = schemas[location];
      if (!schema) {
        continue;
      }
      const { error, value } = schema.validate(req[location], {
        abortEarly: false, // collect every problem
        convert: true, // "12" -> 12 for query strings; trim() and default() also need it
        allowUnknown: false, // the default: unknown keys are errors (stripUnknown: true would drop them silently)
      });
      if (error) {
        errors.push(...error.details.map((d) => ({ location, path: d.path.join('.'), code: d.type, message: d.message })));
      } else {
        valid[location] = value;
      }
    }

    if (errors.length > 0) {
      return next(new ValidationError(errors));
    }
    res.locals.valid = valid;
    next();
  };
}

export function createApp() {
  const app = express();
  app.use(express.json({ limit: '10kb' }));
  app.post('/api/v1/books', validateJoi({ body: createBook }), (req, res) => res.status(201).json(res.locals.valid.body));
  app.get('/api/v1/books', validateJoi({ query: listBooks }), (req, res) => res.json(res.locals.valid.query));
  app.use(validationErrorHandler);
  return app;
}

What to set deliberately:

  • abortEarly: false reports every problem; the default stops at the first.

  • Unknown keys are errors unless allowUnknown: true or stripUnknown: true is set. Keep the default for request bodies; stripUnknown: true drops extras silently, which hides client mistakes and mass-assignment probes.

  • convert: true (the default) turns '12' into 12, trims strings with .trim() and applies defaults. It is what makes query-string schemas work and what makes JSON bodies too lenient, so mark fields that must be real numbers with .strict() as the example does for priceCents.

  • A schema validates undefined successfully unless it is .required(). The root Joi.object({...}).required() in the example is what rejects a missing body (verified: without it, a POST with no body returned 201).

  • Joi.string().guid() takes a version list; Joi.string().isoDate() and Joi.string().uri({ scheme }) cover dates and URLs.

Ajv and JSON Schema

Ajv compiles JSON Schema into a function. The schema is plain JSON, so it can be shared with documentation, with an OpenAPI document and with clients written in other languages.

// ajv-books.mjs
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import express from 'express';
import { ValidationError, validationErrorHandler } from './validate.mjs';

// One instance for JSON bodies: no coercion, defaults applied, extra keys are errors.
const bodyAjv = new Ajv({ allErrors: true, useDefaults: true });
// One instance for query strings: every value arrives as a string, so coerce scalars.
const queryAjv = new Ajv({ allErrors: true, useDefaults: true, coerceTypes: true });
addFormats(bodyAjv);
addFormats(queryAjv);

const createBookSchema = {
  type: 'object',
  additionalProperties: false,
  required: ['title', 'isbn', 'authorIds', 'priceCents'],
  properties: {
    title: { type: 'string', minLength: 1, maxLength: 200 },
    isbn: { type: 'string', pattern: '^\\d{10}(\\d{3})?$' },
    authorIds: { type: 'array', minItems: 1, maxItems: 10, items: { type: 'string', format: 'uuid' } },
    priceCents: { type: 'integer', minimum: 0, maximum: 1000000 },
    publishedOn: { type: 'string', format: 'date' },
    tags: { type: 'array', maxItems: 10, default: [], items: { type: 'string', minLength: 1, maxLength: 30 } },
  },
};

const listBooksSchema = {
  type: 'object',
  additionalProperties: false,
  properties: {
    q: { type: 'string', maxLength: 100 },
    page: { type: 'integer', minimum: 1, maximum: 10000, default: 1 },
    pageSize: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
    sort: { enum: ['title', '-title', 'publishedOn', '-publishedOn'], default: 'title' },
  },
};

const validateBody = bodyAjv.compile(createBookSchema);
const validateQuery = queryAjv.compile(listBooksSchema);

/**
 * Wraps a compiled Ajv function. Ajv coerces and fills defaults IN PLACE, so validate a copy
 * (req.query is a read-only getter in Express 5 and must not be mutated anyway).
 */
function ajvMiddleware(compiled, location) {
  return (req, res, next) => {
    const data = structuredClone(req[location] ?? {});
    if (compiled(data)) {
      res.locals.valid = { ...res.locals.valid, [location]: data };
      return next();
    }
    next(
      new ValidationError(
        compiled.errors.map((e) => ({ location, path: e.instancePath.slice(1).replaceAll('/', '.'), code: e.keyword, message: e.message })),
      ),
    );
  };
}

export function createApp() {
  const app = express();
  app.use(express.json({ limit: '10kb' }));
  app.post('/api/v1/books', ajvMiddleware(validateBody, 'body'), (req, res) => res.status(201).json(res.locals.valid.body));
  app.get('/api/v1/books', ajvMiddleware(validateQuery, 'query'), (req, res) => res.json(res.locals.valid.query));
  app.use(validationErrorHandler);
  return app;
}

Important behaviours:

  • Create the instance once at startup and call compile() once per schema; compiled functions are fast, compiling per request is not.

  • additionalProperties: false rejects unknown keys, the JSON Schema form of z.strictObject. The option removeAdditional would silently delete them instead.

  • Formats such as uuid, date and email are not part of core Ajv 8: install ajv-formats and call addFormats(ajv).

  • coerceTypes and useDefaults modify the data you pass in. That is why the middleware validates a structuredClone copy; the original req.query also cannot be mutated in Express 5. Use two instances, one with coercion for query strings and one without for JSON bodies.

  • allErrors: true reports every problem but keeps evaluating after the first failure, which costs more on adversarial input with expensive keywords such as pattern. The Ajv security page discusses this, and the limits on schema complexity; never compile a schema that came from a request.

  • For draft 2019-09 or 2020-12 import Ajv2019 or Ajv2020 from ajv/dist/2019 and ajv/dist/2020; the default class implements draft 7.

  • Ajv’s strict mode (on by default) throws at compile time for unknown keywords and ambiguous schemas, which catches typos in the schema before any request arrives.

Mass assignment

Mass assignment (also called over-posting or autobinding) happens when a handler copies a request object into a model without choosing which fields may be copied. The attacker adds a field the form never showed, such as role, ownerId, isAdmin, price or id, and the application stores it. The pattern looks like one innocent line in several forms: building a record from the whole body, spreading the body into an update, or passing the body as the $set of an update. It has two variants that appeared in widely read Node.js tutorials: a registration endpoint that took the user’s role from the body, so anyone could register as an administrator, and an update endpoint that left the author field in the update document, so a post could be reassigned to another user. The fix is the same for both and it is structural, not a blacklist:

  1. Define the fields a client may send for each operation, as an allow-list (a schema that names them), and reject or strip everything else.

  2. Keep fields that only the server may set (identity, ownership, role, timestamps, status, prices, counters) out of the input schema. The server computes them or reads them from the authenticated context.

  3. Take the identity of the actor from the authenticated session or token (req.user), never from the body or from a URL segment that the caller can edit. For a resource owned by someone, check ownership in the query itself (for example filter by both the id and the owner) so that a valid id of someone else’s record is a not-found.

  4. Define the output the same way: map records to a response shape that omits secrets such as the password hash.

The Bookshelf users router applies all four with Zod. pick() builds the registration schema from one canonical record schema, so a field added to the record never becomes writable by accident.

// users-router.mjs
import { randomBytes, randomUUID, scrypt as scryptCallback } from 'node:crypto';
import { promisify } from 'node:util';
import express from 'express';
import { z } from 'zod';
import { validate, validationErrorHandler } from './validate.mjs';

const scrypt = promisify(scryptCallback);

// The full record, including everything only the server may set.
const userRecord = z.object({
  id: z.uuid(),
  email: z.email().max(254),
  displayName: z.string().trim().min(1).max(60),
  password: z.string().min(12).max(128),
  role: z.enum(['reader', 'editor', 'admin']),
  createdAt: z.iso.datetime(),
});

// What a client may send. pick() is an allow-list: role, id and createdAt cannot get in.
export const registerUser = userRecord.pick({ email: true, displayName: true, password: true }).strict();
export const updateProfile = userRecord.pick({ displayName: true }).partial().strict();

// What a client may see. The password hash is simply not in this list.
const toPublicUser = ({ id, email, displayName, role }) => ({ id, email, displayName, role });

async function hashPassword(password) {
  const salt = randomBytes(16);
  const hash = await scrypt(password, salt, 64);
  return `scrypt$${salt.toString('base64')}$${hash.toString('base64')}`;
}

export function createUsersRouter({ users = new Map() } = {}) {
  const router = express.Router();

  router.post('/', validate({ body: registerUser }), async (req, res) => {
    const { email, displayName, password } = res.locals.valid.body;
    const user = {
      id: randomUUID(),
      email,
      displayName,
      passwordHash: await hashPassword(password),
      role: 'reader', // decided by the server, never by the request
      createdAt: new Date().toISOString(),
    };
    users.set(user.id, user);
    res.status(201).json(toPublicUser(user));
  });

  // The identity comes from the authenticated session or token (req.user), never from the URL or the body.
  router.patch('/me', validate({ body: updateProfile }), (req, res) => {
    const user = users.get(req.user?.id);
    if (!user) {
      return res.status(401).type('application/problem+json').json({ title: 'Unauthorized', status: 401 });
    }
    Object.assign(user, res.locals.valid.body);
    res.json(toPublicUser(user));
  });

  return router;
}

export function createApp() {
  const app = express();
  const users = new Map();
  app.use(express.json({ limit: '10kb' }));
  // Stand-in for real authentication, for the demo only.
  app.use((req, res, next) => {
    const id = req.get('x-demo-user');
    if (id) req.user = { id };
    next();
  });
  app.use('/api/v1/users', createUsersRouter({ users }));
  app.use(validationErrorHandler);
  return app;
}

The suite in Testing the contract sends such an attack and checks that it is refused, which is the regression test every writable endpoint should have.

Why not a deny-list? A list of forbidden fields (delete body.role) fails open: the next field somebody adds to the model is writable until somebody remembers to extend the list. An allow-list fails closed. The same holds for ORMs: Mongoose’s strict mode removes only keys that are not in the schema, and role is in the schema; Sequelize’s fields option and Prisma’s explicit data object are allow-lists you must supply. Passing a validated object, never the raw body, is the simplest rule.

The password example also shows two output rules. The response is built field by field by toPublicUser, so the hash cannot leak by adding a column. And hashing uses scrypt from node:crypto with a random salt, asynchronously so the event loop stays free; production systems normally use a maintained library such as argon2 or bcrypt, as described in Authentication and Authorization.

Headers, cookies and the other inputs

Headers are client input like any other. Node.js lower-cases the names, joins repeated headers with a comma for most fields, and keeps only the first value of some singleton headers (such as Content-Type, Content-Length, Authorization and User-Agent), so duplicate-header tricks usually fail early. What remains is a set of rules:

Header Handling

Content-Type

A parser only fills req.body for a matching type. Use req.is('json') to require it and answer 415 for anything else; without a match req.body is undefined. Never let one route accept both a form and JSON unless it validates both.

Authorization

Parse the scheme and token strictly (Bearer followed by one space and a token of expected length and alphabet) before passing it to a verifier. Never log it.

Host, X-Forwarded-Host, X-Forwarded-Proto, X-Forwarded-For

Writable by the client unless a trusted proxy overwrites them. req.hostname, req.protocol and req.ip use the forwarding headers only when trust proxy is configured for the proxies actually in front. Building a password-reset link from req.hostname without a configured trusted host list is a host-header injection. See Behind Proxies.

Origin, Referer

Useful for CORS and CSRF checks, never an authorisation. Compare against an allow-list; see CORS.

User-Agent and other free text

Cap the length and treat it as hostile when logged or rendered. Newlines in logged values forge log lines (see Log injection and command injection).

Custom headers (Idempotency-Key, X-Request-Id)

Validate with a schema like any other field, as the idempotencyHeaders schema above does: a UUID, optional, rejected when malformed. Echo a request ID back only after validating it, otherwise generate a new one.

Cookies parsed by cookie-parser are strings, and a signed cookie that fails verification is false in req.signedCookies. Validate their shape exactly as you would a header, and keep authority on the server: a session cookie should hold an opaque identifier, not a role. Details are in Cookies and Sessions.

Response headers deserve the same care in the opposite direction. Node.js refuses header values with control characters (ERR_INVALID_CHAR), which blocks classic response splitting, but a user-controlled value still does harm in Location (open redirect, see Open redirects) or Content-Disposition. Let res.attachment() and res.download() encode file names rather than concatenating them.

Size limits

A schema checks the shape of data that has already been read into memory. Limits keep hostile input from being read at all, so they are the first line of defence and they are cheap.

Limit Where Default and advice

Request body size

express.json({ limit }), express.urlencoded({ limit }), express.raw, express.text

100kb by default. Set the smallest value each route needs, with a larger limit mounted only on the routes that need it. Over the limit gives 413.

Number of form parameters

express.urlencoded({ parameterLimit })

1000 by default; forms rarely need more than a few dozen.

Nesting depth

express.urlencoded({ extended: true, depth }), qs options for the extended query parser

body-parser allows 32 levels by default with extended: true. Keep nesting out of public APIs by staying on the flat parsers.

Array length and string length

Schema: .max(), maxItems, maxLength, isArray({ max })

Always bound arrays and strings. An array of one million small items fits under a megabyte of JSON and costs real work.

JSON nesting

Parser strict, schema shape

express.json() parses with JSON.parse; deeply nested documents cost stack and time. The body limit bounds them; the schema rejects unexpected nesting.

Header size and count

Node.js http server: maxHeaderSize (16 KiB by default), headersTimeout, requestTimeout

See Server Timeouts, Limits and Hardening.

Uploads

multer limits

Set fileSize, files, fields and fieldSize; see File Uploads and Downloads.

Request rate

express-rate-limit

Limits how often, complementing how much; see Security Best Practices.

A route-specific limit looks like this, and the default 413 response should be turned into a problem document by the central error handler:

import express from 'express';

const app = express();

// The route that accepts an import file is registered first, with its own larger parser.
app.post('/api/v1/imports', express.json({ limit: '2mb' }), (req, res) => {
  res.status(202).json({ accepted: Array.isArray(req.body?.books) ? req.body.books.length : 0 });
});

// Every route registered after this line gets the small default.
app.use(express.json({ limit: '10kb' }));

Order matters. A global parser mounted first would read, and reject at 10 kilobytes, the import body before the route’s own parser ever ran. Register the exception before the global parser, or give the global parser a path prefix. A parser that has already consumed the body leaves nothing for a second one.

Injection: validating is not escaping

A valid email address can still contain a single quote, and a 200-character title can still be JavaScript. Schemas narrow what arrives; each sink needs its own defence at the point of use.

SQL injection

Send values as parameters, never concatenated into the SQL text. Every driver supports it ($1 placeholders in pg, ? in mysql2), as do query builders and ORMs when used as documented. Identifiers (table, column, sort direction) cannot be parameters, so map the validated choice to a fixed string:

// safe-sort.mjs
const SORT_COLUMNS = new Map([
  ['title', 'title ASC'],
  ['-title', 'title DESC'],
  ['publishedOn', 'published_on ASC'],
  ['-publishedOn', 'published_on DESC'],
]);

export function listBooksQuery({ q, page, pageSize, sort }) {
  // Values travel as parameters; the identifier comes from a fixed map, never from the request text.
  const text = `
    SELECT id, title, isbn, price_cents, published_on
    FROM books
    WHERE ($1::text IS NULL OR title ILIKE '%' || $1 || '%')
    ORDER BY ${SORT_COLUMNS.get(sort) ?? 'title ASC'}
    LIMIT $2 OFFSET $3`;
  return { text, values: [q ?? null, pageSize, (page - 1) * pageSize] };
}

The sort value here is an enum already checked by the schema, and the map still protects the query if a later change loosens the schema. A LIKE pattern needs one more step: % and _ in the search text are wildcards, so escape them (and the escape character) if the user’s text must match literally. The full treatment of drivers, pools and transactions is in Database Integration.

NoSQL injection

MongoDB filters are objects, so an attacker who can send an object where a string was expected can send an operator. A login handler that puts req.body.email straight into a filter is attacked with a JSON body whose email is { $ne: null }, which matches every user. Two defences stack:

  1. A schema that demands a string (z.string(), Joi.string(), isString(), Ajv type: 'string') rejects the object before it reaches the driver. This is the primary defence and is the reason typed validation matters more than character filtering.

  2. Mongoose 6.0.8 and later can strip operators from untrusted filters, globally or for one filter.

// find-user.mjs
import mongoose from 'mongoose';

// Option 1: make Mongoose neutralise query operators in every filter (Mongoose 6.0.8 and later).
mongoose.set('sanitizeFilter', true);

export function findByEmail(User, email) {
  // email is already a string here, because the schema rejected objects such as { $ne: null }.
  return User.findOne({ email });
}

// Option 2: wrap a single, untrusted filter explicitly.
export function findByUntrustedFilter(User, untrusted) {
  return User.find(mongoose.sanitizeFilter(untrusted));
}

Also reject $where and server-side JavaScript, keep $regex out of user control, and cast identifiers with the schema (a malformed ObjectId should be a 400, see MongoDB and Mongoose). Middleware that scrubs $ and . from req.body and req.query is the Express 4 era answer; on Express 5 it cannot rewrite req.query, and a schema is stricter anyway.

HTML and cross-site scripting

Cross-site scripting is an output problem: the data is dangerous only when it is placed in an HTML page. Encode at output, using a template engine with automatic escaping and its escaping tags (see Template Engines and Views), and send a Content Security Policy (see Security Best Practices). Do not HTML-escape input before storing it: the stored value is then wrong for JSON, CSV, emails and search. When a field legitimately stores markup, such as a rich-text review, sanitise it with an allow-list library on the way in and still render it deliberately:

// review-html.mjs
import sanitizeHtml from 'sanitize-html';

const options = {
  allowedTags: ['p', 'em', 'strong', 'a', 'ul', 'ol', 'li', 'blockquote'],
  allowedAttributes: { a: ['href', 'rel'] },
  allowedSchemes: ['http', 'https', 'mailto'],
  // Force safe link behaviour on everything that survives.
  transformTags: { a: sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer nofollow' }) },
};

/** For the rare field that must store HTML (a rich-text review). Plain text fields should not use this. */
export function cleanReviewHtml(dirty) {
  return sanitizeHtml(dirty, options);
}

Checked output: <script> and event attributes are removed, a javascript: link loses its href, and every surviving link gets rel forced. sanitize-html 2.18 declares Node.js 22.12 or later; check the engines field of the version you install. Libraries that run a regex over request objects to "clean" everything (xss-clean and similar) are not a substitute for output encoding, and the common ones are no longer maintained.

Path traversal

A file name from the request must never be joined into a path unchecked: ../../etc/passwd and encoded variants escape the folder. Allow-list the name with a schema (letters, digits, underscore and hyphen followed by .pdf), resolve against a fixed directory, and confirm the result is still inside it. res.sendFile() and res.download() accept a root option that confines the path and reject traversal; express.static does the same for the folder it serves. Never use req.file.originalname as the storage name; generate your own.

import path from 'node:path';
import express from 'express';

const app = express();
const INVOICES = path.resolve('storage/invoices');

app.get('/api/v1/invoices/:name', (req, res, next) => {
  if (!/^[\w-]{1,64}\.pdf$/.test(req.params.name)) {
    return res.status(400).type('application/problem+json').json({ title: 'Invalid file name', status: 400 });
  }
  // root confines the lookup to INVOICES; a traversal attempt is rejected by the underlying send module.
  res.sendFile(req.params.name, { root: INVOICES, dotfiles: 'deny' }, (error) => {
    if (error) next(error);
  });
});

Open redirects

res.redirect(req.query.next) after login sends users anywhere an attacker puts in a link. Accept only a same-site path. The check is easy to get wrong: //evil.example and /\evil.example look like paths and are treated as hosts by browsers. The helper below was run against both, against absolute URLs, a javascript: URL, a value passed twice (next=/a&next=/b) and a path with a tab character; each returns the fallback.

// safe-redirect.mjs
const ORIGIN = 'http://placeholder.invalid';

/** Returns a same-site path (with query and hash) or the fallback. Never returns an absolute URL. */
export function safeLocalPath(candidate, fallback = '/') {
  if (typeof candidate !== 'string' || !candidate.startsWith('/') || candidate.startsWith('//') || candidate.includes('\\')) {
    return fallback;
  }
  const url = new URL(candidate, ORIGIN);
  return url.origin === ORIGIN ? `${url.pathname}${url.search}${url.hash}` : fallback;
}
for (const c of ['/books?id=1', '//evil.example', '/\\evil.example', 'https://evil.example', '/%2f%2fevil.example', 'javascript:alert(1)', ['/a'], undefined, '/\t/evil.example', '/ /evil.example'])
  console.log(JSON.stringify(c), '->', safeLocalPath(c));

An alternative that scales to several permitted destinations is to accept a short key (next=books) and map it to a path on the server.

Prototype pollution

JSON.parse creates an own property named __proto__ when the document contains one; checked on 5.3.0, a body of {"__proto__":{"admin":true}} reaches req.body with that own key. Nothing is polluted yet. The damage comes from code that recursively merges or assigns untrusted objects (merge(target, req.body), Object.assign into shared configuration, lodash.merge-style helpers of old versions), which can write to Object.prototype and change the behaviour of every object. Defences, in order of strength: validate into a new object whose keys are declared (every schema above builds one); never recursively merge request data into existing objects; when you must keep a dictionary, use new Map() or Object.create(null); keep dependencies current; and consider running Node.js with --disable-proto=delete where the application never uses __proto__.

Regular expressions and denial of service

A regular expression with nested quantifiers can take exponential time on a crafted string (ReDoS) and blocks the event loop while it does. Bound the length of the input before matching (body limit and .max()), write patterns without ambiguous repetition, prefer library validators over hand-written email or URL expressions, and never build a pattern from request text with new RegExp(req.query.q); escape the text, or search with includes().

Log injection and command injection

A value containing a newline written to a plain-text log can forge entries. Log structured JSON (see Debugging and Logging), which escapes control characters, and log the validated values rather than raw headers. Never pass request data to a shell: use execFile or spawn with an argument array and no shell, validate the arguments first, and avoid user-controlled paths and flags that start with -. For the node:child_process details see Node.js: child_process.

Testing the contract

Validation is behaviour, and it regresses quietly when a schema is loosened. Test both directions: a valid request is accepted and returns the parsed shape, and every attack in this page is refused. The built-in test runner and fetch are enough for a small suite; see Testing for supertest, the app and server split and integration setups.

// books-validation.test.mjs
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { createApp } from './app.mjs';

describe('POST /api/v1/books', () => {
  let server;
  let base;

  before(async () => {
    server = createApp().listen(0);
    await new Promise((resolve) => server.once('listening', resolve));
    base = `http://127.0.0.1:${server.address().port}`;
  });

  after(() => server.close());

  const post = (body) =>
    fetch(`${base}/api/v1/books`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(body),
    });

  const valid = { title: 'Dune', isbn: '9780441172719', authorIds: ['7d0a6f1e-3b5e-4c0a-8f57-1c2d3e4f5a6b'], priceCents: 999 };

  it('creates a book from a valid body', async () => {
    const response = await post(valid);
    assert.equal(response.status, 201);
    assert.deepEqual((await response.json()).tags, []);
  });

  it('rejects a field the schema does not know (mass assignment)', async () => {
    const response = await post({ ...valid, ownerId: 'someone-else' });
    assert.equal(response.status, 422);
    const problem = await response.json();
    assert.equal(problem.errors[0].code, 'unrecognized_keys');
  });

  it('reports every problem at once', async () => {
    const response = await post({ title: '', isbn: 'x', authorIds: [], priceCents: -1 });
    assert.equal(response.status, 422);
    assert.equal((await response.json()).errors.length, 4);
  });

  it('rejects an array where a string is expected in the query string', async () => {
    const response = await fetch(`${base}/api/v1/books?q=a&q=b`);
    assert.equal(response.status, 422);
  });

  it('answers 413 for a body over the limit', async () => {
    const response = await post({ ...valid, title: 'x'.repeat(20_000) });
    assert.equal(response.status, 413);
  });
});

Run it with node --test. The suite starts the app on a random port, so it needs no fixed port and runs in parallel with other suites. Keep one such test per rule that has security value: unknown field rejected, array where a string is expected, oversized body, bad identifier, missing body.

The removed req.assert() API

Older Express tutorials, including a book that used express-validator 2, validate with req.assert('email', 'Invalid email').isEmail() and read the failures from req.validationErrors(). That API came from the first generation of express-validator, which patched the request object through an expressValidator() middleware mounted after the body parser and added req.assert(), req.check(), req.checkBody(), req.sanitize() and req.validationErrors(). It was superseded by the chain API (check() and later body(), query(), param()) introduced in version 4, and it is gone from versions 6 and 7. Code that still calls req.assert throws TypeError: req.assert is not a function.

Legacy (express-validator 2 and 3) Current (express-validator 7)

app.use(expressValidator()) after the body parser

Nothing to mount globally. Rules are middleware attached to routes.

req.assert('email', 'Invalid').isEmail() inside the handler

body('email').isEmail().withMessage('Invalid') before the handler.

req.checkBody(...), req.checkQuery(...), req.checkParams(...)

body(), query(), param(), header(), cookie(); or check() for any location.

req.sanitize('name').trim()

body('name').trim(); the sanitising chain methods run with the validators.

req.validationErrors() returned an array or false; req.getValidationResult() returned a promise.

validationResult(req) returns a result object synchronously: isEmpty(), array(), mapped().

error.param

error.path (renamed in version 7), with error.type === 'field'.

import { check } from 'express-validator/check'

import { check } from 'express-validator'; the sub-paths were removed in version 7.

Migrate by moving each assertion out of the handler into a rule array on the route, adding checkExact() where the old code trusted the body, and replacing the handler’s error check by a shared middleware such as checkRules above. The official migration guides list every renamed item. Note the larger lesson: assertions inside a handler are easy to skip, while rules attached to the route are part of its declaration.

What changed recently

Version Change relevant to input handling

Express 5.0.0 (2024-09)

req.query is a read-only getter; the default query parser is simple; req.body is undefined without a parser; express.urlencoded defaults to extended: false; req.param() removed.

Express 5.2 (2025-12-01) and 5.3 (2026-10-09)

Dependency and security updates, including qs 6.16 fixes in 5.3.0; the input semantics above are unchanged. Read History.md before upgrading.

Zod 4 (2025)

New default major of zod. Top-level z.email(), z.uuid(), z.iso.date(), z.int(), z.strictObject() and z.stringbool(); z.flattenError(), z.treeifyError() and z.prettifyError() replace .flatten() and .format(); unified error customisation; z.toJSONSchema().

express-validator 7

Error param renamed path; check/filter sub-path imports and sanitize* functions removed; checkExact() available; Node.js 14 or later at the time of release.

Joi 18

Requires Node.js 20 or later.

Ajv 8

Strict mode on by default; formats moved to ajv-formats; draft 2019-09 and 2020-12 through separate entry points.

Mongoose 7 and later

strictQuery defaults to false, so unknown filter keys are passed to MongoDB; use schema validation and sanitizeFilter instead of relying on strictQuery.

Where the books are outdated

The books consulted for this section mostly do not validate input at all, and where they do it is with a library API that no longer exists. Their habits, by topic:

Older practice Where it appears Current behaviour

Reading fields from req.body and storing them with no schema, length or type check.

Sharma, chapters 3 to 6; Bugl, chapters 3 to 6; Monteiro 2014, chapters 2 to 4

Define a strict schema per operation, validate params, query, headers and body, and read only the parsed result.

req.assert(), req.validationErrors() and the expressValidator() middleware of express-validator 2.

Monteiro 2016, chapter 10

Removed in versions 6 and 7; use body(), validationResult() and matchedData() (see The removed req.assert() API).

Taking the role from the registration body, so a client can choose its own role.

Sharma, chapter 6

The role is set by the server; the registration schema is an allow-list (see Mass assignment).

Leaving the author field in the update document, so a post can be reassigned to someone else.

Bugl, chapter 6

Update schemas omit ownership fields, and the owner comes from the authenticated user and is part of the query.

Returning the saved user, including the password hash, in the response.

Sharma, chapter 6

Map records to a public shape field by field.

Passing the body, or a query-string value, straight into a Mongoose filter.

Common in tutorials of the period

Require strings by schema and enable sanitizeFilter; MongoDB operators arrive as objects.

Integer identifiers handed to findById, and unchecked identifiers in the URL.

Sharma, chapter 3

Validate the identifier format (UUID or ObjectId) before the query and answer 400 or 404, not 500.

Writing user-supplied upload names into a public folder.

Monteiro 2016, chapter 3

Generate the stored name; see Path traversal.

Relying on body-parser as a separate package and on extended: true by habit.

Express 4-era tutorials

express.json() and express.urlencoded() are built in; extended defaults to false in Express 5.

Assuming req.body is always an object and req.query can be edited.

Express 4-era tutorials

req.body is undefined without a parser, and req.query is a read-only getter on Express 5.

No book consulted for this section uses Helmet, rate limiting, input validation or CSRF protection. Validation is added here and the others in Security Best Practices.

Common pitfalls

Symptom Usual cause Fix

TypeError: Cannot destructure property of undefined on a POST.

req.body is undefined: no parser, or the Content-Type did not match.

Mount the parser first, require the content type, and validate body as a required object.

TypeError: Cannot set property query after upgrading to Express 5.

A sanitising middleware assigns req.query.

Write parsed values to res.locals, or replace the middleware.

?tag=a&tag=b crashes a handler.

The code expected a string and received an array.

The schema must say string; arrays only where declared.

A numeric field accepts 1e3, 0x10 or ['5'].

Number() or z.coerce.number() used on raw text.

Match the text with a pattern first, then convert.

z.coerce.boolean() makes 'false' true.

Every non-empty string is truthy.

Use z.stringbool() or an explicit enum.

An unknown field is stored.

Default z.object strips quietly, a raw req.body was used, or checkExact is missing.

Use strict schemas and pass only parsed values.

Joi accepts a request with no body.

The root schema is not .required().

Add .required() to the root schema.

express-validator passes an array to a string rule.

Standard validators run per element of an array.

Start the chain with isString().bail().

A sort or filter parameter reaches SQL text.

The value was concatenated instead of mapped.

Use a fixed map for identifiers and placeholders for values.

Validation passes in tests and fails in production behind a proxy.

trust proxy differs, so req.ip, req.protocol and req.hostname differ.

Configure the setting identically and test with the forwarding headers.

The 413 or 400 from the parser is HTML.

The error handler is not registered, or runs before the parser errors.

Register the JSON error handler last.