Server-Side Input Validation in Express with express-validator Chains
Learn why Express APIs must validate input on the server, how express-validator chains record errors for validationResult, and how to structure a production route.
This article is published in English.
Sources: Stop Trusting User Input: A Beginner’s Guide to `express-validator` in Express.js
Sooner or later, every Express API that accepts input receives something like "age": "banana", and the server either crashes or, worse, stores it. Validation is the layer that stops this, and doing it well on the backend matters no matter how careful your frontend forms are. This guide explains what validation is and why the server has to own it, shows the manual approach and where it breaks down, and then walks through how express-validator works internally, which methods you will use most, and how a realistic route combines validation, sanitization and database-backed checks.
What validation means
Validation is the act of confirming that incoming data has the shape and content you expect before any of your code acts on it. A useful analogy is a door check at a venue: it does not matter how confident someone looks, the staff check their ID, and if it does not pass, they do not get in. Validation applies the same scrutiny to each field of a request. Is this actually an email address? Is this really a number? Is the field present at all?
Consider a signup endpoint. A well-formed request body might look like this:
{
"name": "Rahul Sharma",
"email": "rahul@example.com",
"age": 25,
"password": "supersecret123"
}
Validating it means confirming that email is formatted like an email address, that age is a number rather than text, and that password meets a minimum length that makes it more than a formality.
Why the backend cannot rely on frontend checks
A common objection is that the signup form already marks fields as required and checks the email format in the browser. That is not enough, because the frontend is optional from an attacker's point of view. Anyone can call your API directly with Postman, curl or a script and bypass every rule in your UI. If the server does not verify the data itself, it is effectively trusting whoever sends the request.
The concrete benefits of server-side validation:
- It protects your data. When a column expects a number and receives
"twenty-five", you either get an error or, worse, a malformed record that causes bugs much later. - It shrinks your attack surface. Unchecked input is a common entry point for injection attacks such as SQL injection and cross-site scripting (XSS). Validation and sanitization reduce that exposure, although they complement rather than replace parameterized queries and proper output encoding.
- It prevents confusing failures. Many production crashes trace back to a missing, empty or wrongly typed field that was used a few lines before the actual error.
- It produces useful error messages. Rather than a generic "Something went wrong", clients can be told exactly what to fix, such as "Email is not valid" or "Password must be at least 8 characters".
The division of labor is simple: frontend validation exists to help users, and backend validation exists to protect the application. Both are valuable, but only the backend layer is mandatory.
Validating by hand, and why it stops scaling
It is worth seeing the manual version first. Here is the start of a registration route that pulls the fields from the parsed body (this assumes a JSON body parser such as express.json() is registered):
app.post("/register", (req, res) => {
const { name, email, age, password } = req.body;
Each field then gets its own guard clause returning a 400 response:
if (!name || typeof name !== "string" || name.trim().length === 0) {
return res.status(400).json({ error: "Name is required" });
} if (!email || !email.includes("@")) {
return res.status(400).json({ error: "A valid email is required" });
} if (!age || typeof age !== "number" || age < 18) {
return res.status(400).json({ error: "Age must be a number and at least 18" });
} if (!password || password.length < 8) {
return res.status(400).json({ error: "Password must be at least 8 characters" });
} // If we made it here, the data is safe to use
res.json({ message: "User registered successfully" });
});
For a small app this is perfectly acceptable. As the codebase grows, though, the problems accumulate:
- Every route that accepts input needs its own block of
ifstatements. - Checks are copied between routes, and a fix applied to one copy is easily missed in the others.
- The real business logic, registering the user, is buried beneath the validation code.
- Covering every edge case by hand, such as empty strings,
null, wrong types, and values that are too short or too long, quickly becomes tedious.
The manual version also has subtle gaps. email.includes("@") accepts plenty of invalid addresses, and calling .includes on a non-string value throws. Repetitive, error-prone checking of this kind is exactly what a library should handle.
What express-validator provides
express-validator is a collection of Express middleware built on top of the validator.js library. Instead of writing conditional logic for each field, you declare rules, and the library evaluates request data against them. Start by importing the two main functions:
const { body, validationResult } = require("express-validator");
The same registration route, rewritten with declarative rules, looks like this:
app.post(
"/register",
[
body("name").trim().notEmpty().withMessage("Name is required"),
body("email").isEmail().withMessage("A valid email is required"),
body("age").isInt({ min: 18 }).withMessage("Age must be at least 18"),
body("password").isLength({ min: 8 }).withMessage("Password must be at least 8 characters"),
],
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
} res.json({ message: "User registered successfully" });
}
);
The rules now read close to plain language: the name must not be empty after trimming, the email must be an email, the age must be an integer of at least 18, and the password must be at least eight characters. The handler at the end only deals with one question: did anything fail?
What happens when a request arrives
The most common beginner misunderstanding is expecting express-validator to reject bad requests automatically. It does not. It gathers information about what is wrong and leaves the response entirely to you. The sequence for each request is:
- Express starts the middleware chain defined for the route and runs each entry in order.
- Each validation chain runs as middleware. When
body("email").isEmail()executes, it readsemailfromreq.bodyand tests it. A failure does not throw or end the request; the chain records the error on the request object and moves on. - Every rule gets its turn. Because a failing chain still calls the next middleware, all rules run and every problem is recorded, not just the first.
validationResult(req)reads the recorded errors. It is an ordinary function, not middleware, so you call it inside your handler. It does not validate anything itself; it reports what the chains already collected.- Your code decides the outcome. If
errors.isEmpty()returnsfalse, you send a 400 with details. Otherwise you proceed with the real work.
The design favors flexibility over convenience. Because rejection is not automatic, you control the response format and timing, and you can run other logic between the checks and the final decision. The trade-off is that forgetting to call validationResult() silently disables validation for that route, which is why many teams wrap the check in a small reusable middleware.
The four building blocks
Once you know the pieces, the library is straightforward.
Validation chains
Functions such as body(), query(), param() and check() create a chain: a sequence of rules joined with dots, like body("email").trim().isEmail(). Each targets a different part of the request:
body()readsreq.body, the JSON or form payload.query()readsreq.query, the query string after?, as in?page=2.param()readsreq.params, route parameters such as:idin/users/:id.check()looks in all request locations at once.
Validators
Validators are the functions that test a value: isEmail(), isInt(), isLength(), isAlphanumeric() and many more. Most delegate to validator.js, which already contains mature, well-tested logic for common formats.
Sanitizers
Sanitizers transform data rather than just checking it. trim() strips surrounding whitespace, normalizeEmail() converts an address into a canonical form, and escape() replaces HTML-significant characters with their entity equivalents so they cannot be interpreted as markup. Sanitizers can be mixed with validators in the same chain, and order matters: a trim() placed before notEmpty() means a value consisting only of spaces fails.
The per-request result store
Whenever a chain runs, its failures are attached to that specific req object. validationResult(req) simply reads from that store, which is why it needs the same request object the chains processed.
Taken together, the flow is: a request arrives, the chains run and quietly record problems, the handler asks for the results, and you choose the response. If you are weighing this approach against schema-based validation, our comparison of Zod and express-validator looks at the trade-offs.
The methods that cover most real projects
You do not need the whole API. A small set handles most cases.
Location selectors
body(field)for fields in the request bodyquery(field)for query string parametersparam(field)for route parameters
Common validators
.notEmpty(): the value must not be empty.isEmail(): the value must be a plausible email address.isInt({ min, max }): the value must be an integer, optionally within bounds.isLength({ min, max }): checks the length of a string.isString(): the value must be a string.isBoolean(): the value must be a boolean.matches(regex): the value must match a custom pattern.isIn([...values]): the value must be one of a fixed set, handy for fields like astatusthat is either"active"or"inactive"
Common sanitizers
.trim(): removes leading and trailing whitespace.escape(): escapes HTML characters to reduce injection risk.normalizeEmail(): standardizes email formatting.toInt()and.toFloat(): convert string input to numbers
Custom rules
.custom((value, { req }) => { ... }): implements your own rule when no built-in validator fits, such as confirming that a username is not already registered
Error reporting
.withMessage("text"): sets the message for the preceding rulevalidationResult(req): gathers all errors for the current requesterrors.array(): converts those errors into a plain array suitable for a JSON response
A production-style route
The following example is closer to what a real API contains: a route that creates a blog post, combining validation, sanitization and a check that queries the database. First the imports, where Post stands in for a model such as a Mongoose schema:
const express = require("express");
const { body, validationResult } = require("express-validator");
const Post = require("./models/Post"); // a Mongoose model, for example
Then the router and the route with its rule array:
const router = express.Router();router.post(
"/posts",
[
body("title")
.trim()
.notEmpty().withMessage("Title is required")
.isLength({ max: 100 }).withMessage("Title cannot exceed 100 characters"), body("slug")
.trim()
.notEmpty().withMessage("Slug is required")
.matches(/^[a-z0-9-]+$/).withMessage("Slug can only contain lowercase letters, numbers, and hyphens")
.custom(async (slug) => {
const existing = await Post.findOne({ slug });
if (existing) {
throw new Error("This slug is already taken");
}
return true;
}), body("content")
.trim()
.notEmpty().withMessage("Content cannot be empty")
.isLength({ min: 20 }).withMessage("Content should be at least 20 characters"), body("tags")
.optional()
.isArray().withMessage("Tags must be an array")
.custom((tags) => tags.every((t) => typeof t === "string"))
.withMessage("Each tag must be a string"),
],
async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
} try {
const post = await Post.create(req.body);
res.status(201).json(post);
} catch (err) {
res.status(500).json({ error: "Something went wrong while saving the post" });
}
}
);module.exports = router;
Several details are worth studying:
- Database-backed rules. The
slugchain uses an async.custom()validator to look up existing posts. A regex can confirm a slug's format, but only a query can confirm it is unique. Throwing inside the custom function marks the field as invalid with that message. - Optional fields that are still checked.
tagsis marked.optional(), so a post without tags passes, but when tags are supplied they must be an array of strings. - A focused handler. The route handler has two jobs: report validation errors and save the post. All the "is this data acceptable" logic lives in the rule array, where it is easy to read and change.
Two caveats before copying this pattern. First, a uniqueness check in a validator is subject to a race: two requests with the same slug can both pass the lookup before either is saved, so keep a unique index on the column and handle the duplicate-key error as well. Second, passing req.body straight to Post.create() saves any extra fields the client included, not just the validated ones. express-validator provides matchedData(req), which returns only the fields covered by your chains, and using it closes that mass-assignment gap.
As the application grows, move rule arrays into dedicated modules, for example validators/postValidator.js, and import them into route files, so routes do not turn into walls of validation code.
Wrapping up
Validation is not an optional extra; it determines whether your application can trust the data it processes. Hand-written if checks are fine for a handful of fields but degrade quickly across many routes.
express-validator is not magic. It runs your declared rules as middleware, records failures on the request, and lets validationResult() hand you a report so you decide the response. With that flow in mind, the rest of the library is a toolbox of validators and sanitizers you pick from as needed. The points that matter most in practice:
- Treat backend validation as mandatory, regardless of frontend checks.
- Always call
validationResult(), ideally from one shared middleware, because the library never rejects on its own. - Order sanitizers deliberately within a chain.
- Back uniqueness validators with database constraints.
- Persist
matchedData(req)rather than the raw body.