Input Validation Overview
Every value that arrives from outside your server — request bodies, query strings, route params, headers, cookies — is untrusted until proven otherwise. Express hands you req.body, req.query, and req.params exactly as the client sent them, with no guarantees about shape, type, or intent. Input validation is the discipline of rejecting malformed or hostile data at the edge of your application, before it reaches your business logic, database, or templates. Done well, it turns a class of crashes, injection attacks, and corrupt records into a clean, predictable 400 response.
The risk of trusting input
A handler that reads req.body and forwards it straight into a query or an object is one bad request away from a bug. Clients lie, get-rich-quick scripts probe your endpoints, and even well-meaning frontends send the wrong type after a refactor. Concretely, unvalidated input leads to:
- Crashes — calling
.trim()on a value the client sent as a number throwsTypeError, taking down the request (or the process, if unhandled). - Injection — raw strings flowing into SQL, NoSQL operators (
{ "$gt": "" }), shell commands, or HTML enable injection and XSS. - Mass assignment — spreading
req.bodyinto a model lets a client set fields you never exposed, like{ role: "admin" }. - Corrupt data — an email field containing
"not-an-email"or a missing required field silently rots your database.
// DANGEROUS: req.body is trusted blindly
app.post("/users", async (req, res) => {
const user = await db.users.insert(req.body); // any shape, any fields
res.status(201).json(user);
});
Warning: “The frontend already validates it” is not a defense. Anyone can send requests with
curl, Postman, or a script that bypasses your UI entirely. Validation on the server is the only enforceable boundary.
Validation vs. sanitization
Validation and sanitization are related but distinct, and most robust pipelines do both.
| Validation | Sanitization | |
|---|---|---|
| Question it answers | ”Is this value acceptable?" | "Can I clean this value into a safe form?” |
| Outcome | Pass or reject (400) | Transform the value in place |
| Examples | email is a valid address; age is an integer ≥ 0 | Trim whitespace; strip HTML tags; coerce "42" → 42 |
| When it fails | The request is refused | Usually never — it just normalizes |
Validation decides whether to accept the request at all. Sanitization reshapes accepted input into a canonical, safe representation — trimming, lowercasing, escaping, or coercing types. Sanitizing without validating is dangerous (you might “clean” garbage into something that merely looks valid), so validate first, then sanitize the survivors.
Where validation belongs
In Express, validation is a natural fit for middleware that runs before your controller. The middleware inspects the request, and either calls next() to pass clean data downstream or responds with an error. This keeps the controller focused on business logic and gives you one obvious place to look when input rules change.
const express = require("express");
const app = express();
app.use(express.json());
// Validation middleware: gatekeeper before the controller
function validateUser(req, res, next) {
const { name, email, age } = req.body;
const errors = [];
if (typeof name !== "string" || name.trim() === "") {
errors.push({ field: "name", message: "name is required" });
}
if (typeof email !== "string" || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
errors.push({ field: "email", message: "email must be valid" });
}
if (age !== undefined && (!Number.isInteger(age) || age < 0)) {
errors.push({ field: "age", message: "age must be a non-negative integer" });
}
if (errors.length > 0) {
return res.status(422).json({ error: "Validation failed", details: errors });
}
// Sanitize the survivors, then pass downstream
req.body.name = name.trim();
req.body.email = email.toLowerCase();
next();
}
app.post("/users", validateUser, async (req, res) => {
const user = await db.users.insert(req.body); // now trustworthy
res.status(201).json(user);
});
app.listen(3000);
A controller can validate inline for one-off rules, but pulling the logic into named middleware lets you reuse it across routes and compose it on a Router. The hand-rolled checks above illustrate the pattern; in real apps you delegate this to a schema library (express-validator, Joi, or Zod) that handles edge cases, coercion, and clear error reporting for you.
Returning useful 400 and 422 errors
When validation fails, return a response the client can actually act on: a status code, a human-readable message, and a machine-readable list of which fields failed and why. Use 400 Bad Request for syntactically broken requests (malformed JSON, missing required fields) and 422 Unprocessable Entity when the body is well-formed but semantically invalid (a valid string that isn’t a real email). Many teams use 400 for both — pick a convention and stay consistent.
A POST /users with { "name": "", "email": "nope", "age": -3 } should return:
Output:
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{
"error": "Validation failed",
"details": [
{ "field": "name", "message": "name is required" },
{ "field": "email", "message": "email must be valid" },
{ "field": "age", "message": "age must be a non-negative integer" }
]
}
Tip: Report all validation errors at once, not just the first one. A client filling out a form wants every problem highlighted in a single round trip, not one error per resubmission.
Never echo a raw stack trace or expose internal field names that don’t exist in your public API — error responses are part of your contract and a small part of your attack surface.
Best Practices
- Treat
req.body,req.query,req.params, and headers as untrusted on every route, even internal ones. - Validate at the edge in middleware, before any database or business logic runs, so bad input never reaches deeper layers.
- Validate first, then sanitize — trim, lowercase, and coerce only the values that already passed.
- Allow-list the fields you accept instead of spreading the whole body into a model, to prevent mass-assignment.
- Return structured
400/422responses with a per-fielddetailsarray, and surface all errors in one response. - Reach for a schema library (express-validator, Joi, or Zod) once rules grow beyond a handful of
ifchecks — it centralizes coercion, messaging, and TypeScript types. - Keep error messages helpful for clients but free of internal implementation details and stack traces.