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.
-
Validate on the server, every time, whatever the client already checked. Client-side checks improve the user experience and nothing else.
-
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 |
|---|---|---|
|
Any JSON value: object, array, string, number, boolean, |
Parser |
|
Strings, and arrays of strings for repeated keys; with |
Same schema, with explicit conversion of numbers and booleans. |
|
Strings, arrays of strings for repeated keys, and with the |
A schema that rejects arrays and objects where a string is expected, with explicit conversion. |
|
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 |
Constrain the parameter in the route and in a schema ( |
|
Lower-cased names; strings, or an array only for |
Validate custom headers; never trust forwarding headers unless |
|
Strings from |
Treat like any other string; verify the signature or use a server-side session. |
|
The multipart name, original file name and declared MIME type are client claims. |
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 |
|---|---|
|
Middleware that replaces or edits |
The default |
|
With |
Keys such as |
|
Destructuring |
|
Form fields are flat: strings or arrays of strings. |
|
It searched params, body and query in turn, which made source confusion possible. Read the specific source. |
|
Validate with an array schema, or join and check the result. See Route Paths and Parameters. |
Source: Express: Moving to Express 5
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.
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.bodyand then trustsreq.query.sorthas validated half its inputs. -
Hand the handler the parsed result, not the raw request. The raw
req.bodystill 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 ( |
TypeScript-first schema objects built from code, with |
One definition gives validation, transformation and a TypeScript type; strict objects, unions, refinements; works in the browser too; can emit JSON Schema. |
Coercion ( |
express-validator 7 ( |
Rules chained per field ( |
Express-aware: knows |
Validators run on strings and on each element of an array; field-by-field rules get long; not type-safe by itself. |
Joi 18 ( |
Declarative schema objects ( |
Rich rule set, conditional logic ( |
Requires Node.js 20 or later; |
Ajv 8 ( |
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; |
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(),
});
Source: Zod: Defining schemas
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;
}
Source: Express: Routing
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');
});
Source: Express API: app.listen
The behaviour that matters, each point checked against the code above:
-
z.strictObject({...})fails on unknown keys with the codeunrecognized_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 butz.strictObject()is the documented form. -
z.int()accepts only safe integers, so1.5fails.z.number()accepts any finite float. -
.trim()is a transformation: the parsed value is trimmed (' Dune 'becomesDune), 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()andz.iso.date()are top-level format schemas in Zod 4. The Zod 3 formsz.string().email()andz.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 aZodError, which you would otherwise have to translate. -
Error details are in
error.issues(each withpath,codeandmessage).z.flattenError(error)groups messages by field for forms,z.treeifyError(error)keeps nested structure, andz.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 |
|---|---|---|
|
|
Intended. |
|
|
Exponent notation is accepted; a |
|
|
Hexadecimal is accepted. |
|
|
A one-element array is converted through its string form, so |
|
|
Possible in a JSON body. |
|
rejected in Zod 4 |
Older tutorials rely on |
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=bcan pass a rule written for one string. -
Standard validators operate on the string form of the value.
isInt()accepts both12and'12', and.toInt()is what changes the stored value to a number; a JSON body that sends'12'still passes. Add a.custom()check ontypeofwhen that is not acceptable. -
Wildcards select array items and object properties:
body('authorIds.*')applies the rule to each element and the error path becomesauthorIds[0]. -
matchedData(req)returns only fields that have a rule (includeOptionals: falsedrops absent optional fields), and sanitisers have already been applied. It is the allow-list. Readingreq.bodydirectly bypasses it. -
checkExact()adds anunknown_fieldserror for any field with no rule, the equivalent ofz.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 };paramof version 6 was renamedpath. Other error types (alternative,alternative_grouped,unknown_fields) come fromoneOf()andcheckExact(), so handletype. -
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/checkandexpress-validator/filterentry points and thesanitize*functions are gone: importbody,query,param,headerandcookiefromexpress-validatorand 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;
}
Source: Joi: documentation and hapijs/joi
What to set deliberately:
-
abortEarly: falsereports every problem; the default stops at the first. -
Unknown keys are errors unless
allowUnknown: trueorstripUnknown: trueis set. Keep the default for request bodies;stripUnknown: truedrops extras silently, which hides client mistakes and mass-assignment probes. -
convert: true(the default) turns'12'into12, 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 forpriceCents. -
A schema validates
undefinedsuccessfully unless it is.required(). The rootJoi.object({...}).required()in the example is what rejects a missing body (verified: without it, aPOSTwith no body returned 201). -
Joi.string().guid()takes aversionlist;Joi.string().isoDate()andJoi.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;
}
Source: Ajv: documentation and ajv-formats
Important behaviours:
-
Create the instance once at startup and call
compile()once per schema; compiled functions are fast, compiling per request is not. -
additionalProperties: falserejects unknown keys, the JSON Schema form ofz.strictObject. The optionremoveAdditionalwould silently delete them instead. -
Formats such as
uuid,dateandemailare not part of core Ajv 8: installajv-formatsand calladdFormats(ajv). -
coerceTypesanduseDefaultsmodify the data you pass in. That is why the middleware validates astructuredClonecopy; the originalreq.queryalso cannot be mutated in Express 5. Use two instances, one with coercion for query strings and one without for JSON bodies. -
allErrors: truereports every problem but keeps evaluating after the first failure, which costs more on adversarial input with expensive keywords such aspattern. 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
Ajv2019orAjv2020fromajv/dist/2019andajv/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:
-
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.
-
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.
-
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. -
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 |
|---|---|
|
A parser only fills |
|
Parse the scheme and token strictly ( |
|
Writable by the client unless a trusted proxy overwrites them. |
|
Useful for CORS and CSRF checks, never an authorisation. Compare against an allow-list; see CORS. |
|
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 ( |
Validate with a schema like any other field, as the |
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 |
|
|
Number of form parameters |
|
|
Nesting depth |
|
body-parser allows 32 levels by default with |
Array length and string length |
Schema: |
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 |
|
Header size and count |
Node.js |
|
Uploads |
|
Set |
Request rate |
|
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' }));
Source: Express middleware: body-parser
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] };
}
Source: node-postgres: Queries
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:
-
A schema that demands a string (
z.string(),Joi.string(),isString(), Ajvtype: '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. -
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));
}
Source: Mongoose: API, sanitizeFilter
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);
}
Source: apostrophecms/sanitize-html
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);
});
});
Source: Node.js: Test runner
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) |
|---|---|
|
Nothing to mount globally. Rules are middleware attached to routes. |
|
|
|
|
|
|
|
|
|
|
|
|
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) |
|
Express 5.2 (2025-12-01) and 5.3 (2026-10-09) |
Dependency and security updates, including |
Zod 4 (2025) |
New default major of |
express-validator 7 |
Error |
Joi 18 |
Requires Node.js 20 or later. |
Ajv 8 |
Strict mode on by default; formats moved to |
Mongoose 7 and later |
|
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 |
Sharma, chapters 3 to 6; Bugl, chapters 3 to 6; Monteiro 2014, chapters 2 to 4 |
Define a strict schema per operation, validate |
|
Monteiro 2016, chapter 10 |
Removed in versions 6 and 7; use |
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 |
Integer identifiers handed to |
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 |
Express 4-era tutorials |
|
Assuming |
Express 4-era tutorials |
|
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 |
|---|---|---|
|
|
Mount the parser first, require the content type, and validate |
|
A sanitising middleware assigns |
Write parsed values to |
|
The code expected a string and received an array. |
The schema must say string; arrays only where declared. |
A numeric field accepts |
|
Match the text with a pattern first, then convert. |
|
Every non-empty string is truthy. |
Use |
An unknown field is stored. |
Default |
Use strict schemas and pass only parsed values. |
Joi accepts a request with no body. |
The root schema is not |
Add |
express-validator passes an array to a string rule. |
Standard validators run per element of an array. |
Start the chain with |
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. |
|
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. |
References
-
Joi and hapijs/joi
-
Ajv with its security considerations and type coercion
-
Publisher pages of the consulted books are listed in the section bibliography.
-
Related pages: Body Parsing, Error Handling, Security Best Practices, Building REST APIs, MongoDB and Mongoose, Testing