Validating Input
Everything that arrives in a request is controlled by whoever sent it. A form may be filled in wrongly by mistake, and an attacker may send anything at all: a string where you expect a number, a negative quantity, an extra field such as "isAdmin": true, or a 50 MB name. Validation means checking that incoming data has exactly the shape and values you expect before your code uses it.
In this lesson you will see why hand-written checks become messy, then describe your data with schemas using Zod, a popular validation library, return helpful error messages, and write one middleware that validates the body, query string and route parameters of any route.
The golden rule: validate on the server
Front-end checks such as the HTML required attribute are only for convenience. Anyone can skip them by calling your API directly with curl or a script. The server must always check again. Good validation:
- Rejects wrong types:
"age": "nineteen"is not a number. - Checks ranges and formats: an age between 1 and 120, a valid email, a 10-digit mobile number.
- Limits sizes: names under 60 characters, at most 50 items per page.
- Allows only known fields: unknown fields such as
roleare dropped, so a user cannot make themselves an admin. This attack is called mass assignment. - Converts and cleans values: trims spaces, turns
"2"from a query string into2.
Hand-written checks
For one or two fields, plain code is fine:
app.post("/api/feedback", (req, res) => {
const { name, rating } = req.body ?? {};
if (typeof name !== "string" || name.trim().length < 2) {
return res.status(400).json({ error: "name must have at least 2 letters" });
}
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
return res.status(400).json({ error: "rating must be a whole number from 1 to 5" });
}
res.status(201).json({ name: name.trim(), rating });
});It works, but imagine ten fields, optional values, nested objects and the same rules repeated in the create and update routes. The checks become longer than the actual logic, and it is easy to miss one. A schema library describes the rules once, in a readable way.
Schemas with Zod
Zod lets you describe the expected data as a schema and then check any value against it. Install it with npm install zod (this lesson uses Zod 4).
import { z } from "zod";
const Student = z.object({
name: z.string().trim().min(2, "Name must have at least 2 letters").max(60),
email: z.email("Enter a valid email address"),
age: z.number().int().min(10).max(25),
course: z.enum(["BCA", "BSc", "BTech"]),
phone: z.string().regex(/^[6-9]\d{9}$/, "Enter a 10-digit Indian mobile number").optional(),
});
const good = Student.safeParse({ name: " Priya Sharma ", email: "priya@example.in", age: 19, course: "BCA", isAdmin: true });
console.log(good.success, good.data);
const bad = Student.safeParse({ name: "P", email: "priya@", age: 19.5, course: "MBA", phone: "12345" });
console.log(bad.success);
for (const issue of bad.error.issues) {
console.log(`${issue.path.join(".")}: ${issue.message}`);
}true {
name: 'Priya Sharma',
email: 'priya@example.in',
age: 19,
course: 'BCA'
}
false
name: Name must have at least 2 letters
email: Enter a valid email address
age: Invalid input: expected int, received number
course: Invalid option: expected one of "BCA"|"BSc"|"BTech"
phone: Enter a 10-digit Indian mobile numberWhat happened:
safeParse()never throws. It returns{ success: true, data }or{ success: false, error }. (parse()returns the data directly and throws aZodErroron failure.)datais a clean copy: the spaces around the name were trimmed, and the unknownisAdminfield was removed. Always useresult.data, never the originalreq.body.- Every problem is reported at once, not just the first, so a form can highlight all wrong fields together. Each issue has a
path(which field) and amessage. You can pass your own message as the last argument of most checks. .optional()allows the field to be missing;phonewas present but wrong, so it failed.
| Zod piece | Checks |
|---|---|
z.string().min(2).max(60) | Text with a length range |
z.email(), z.url(), z.uuid() | Common formats (Zod 4 style) |
z.number().int().positive() | Whole numbers above 0 |
z.boolean() | true or false |
z.enum(["SL", "3A"]) | One of a fixed list |
z.array(z.string()).max(5) | An array of up to 5 strings |
.optional(), .default(value) | Field may be missing, or gets a default |
z.coerce.number() | Converts strings such as "2" to numbers first |
Query strings need coercion
Query strings and route parameters are always strings, so z.number() would reject "2". Use z.coerce.number(), which converts first and then checks:
const Query = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(50).default(10),
sort: z.enum(["price", "-price"]).optional(),
});
console.log(Query.parse({ page: "3" }));
console.log(Query.safeParse({ page: "abc" }).error.issues[0].message);{ page: 3, limit: 10 }
Invalid input: expected number, received NaNA reusable validation middleware
Rather than calling safeParse() in every route, write one middleware factory that validates any part of the request. Express 5 makes req.query read-only, so the clean values are stored in a new property, req.valid:
// validate.js
import { z } from "zod";
// validate({ body, query, params }) returns a middleware
export function validate(schemas) {
return (req, res, next) => {
const errors = {};
req.valid = {};
for (const part of ["params", "query", "body"]) {
if (!schemas[part]) continue;
const result = schemas[part].safeParse(req[part] ?? {});
if (result.success) req.valid[part] = result.data;
else errors[part] = z.flattenError(result.error).fieldErrors;
}
if (Object.keys(errors).length > 0) {
return res.status(400).json({ error: "Validation failed", details: errors });
}
next();
};
}z.flattenError() groups the messages by field name, which is easy for a front end to show next to each input. Now use it in an app for train bookings:
// app.js
import express from "express";
import { z } from "zod";
import { validate } from "./validate.js";
const app = express();
app.use(express.json());
const StationCode = z.string().trim().toUpperCase().regex(/^[A-Z]{2,5}$/, "Use a 2 to 5 letter station code");
const NewBooking = z.object({
passenger: z.string().trim().min(2).max(60),
age: z.number().int().min(1).max(120),
from: StationCode,
to: StationCode,
travelClass: z.enum(["SL", "3A", "2A", "1A"]).default("SL"),
});
const ListQuery = z.object({
page: z.coerce.number().int().min(1).default(1),
status: z.enum(["confirmed", "waiting"]).optional(),
});
app.post("/api/bookings", validate({ body: NewBooking }), (req, res) => {
const booking = { pnr: 4521098765, ...req.valid.body };
res.status(201).json(booking);
});
app.get("/api/bookings", validate({ query: ListQuery }), (req, res) => {
res.json({ filters: req.valid.query, data: [] });
});
app.listen(3000);A valid booking with a lowercase station code and an extra role field, then an invalid one:
POST /api/bookings -> 201
{
"pnr": 4521098765,
"passenger": "Farhan Ali",
"age": 34,
"from": "NDLS",
"to": "LKO",
"travelClass": "SL"
}
POST /api/bookings -> 400
{
"error": "Validation failed",
"details": {
"body": {
"passenger": [
"Too small: expected string to have >=2 characters"
],
"age": [
"Too small: expected number to be >=1"
],
"to": [
"Use a 2 to 5 letter station code"
]
}
}
}"ndls" was cleaned to "NDLS", the default class "SL" was added, and role was dropped. The route handler only ever sees clean, checked data.
Validation is not the whole story
Validation checks the shape of data. You still need other protections: parameterised queries against SQL injection (see Connecting to MySQL), escaping output when you put user text into HTML, and checking permissions (is this user allowed to edit this booking?). Validation makes all of these easier, but it does not replace them.
Common mistakes
- Validating only in the browser. Attackers call the API directly. Validate on the server every time.
- Using
req.bodyafter validating. Use the cleanedresult.data; the raw body still contains unknown fields and untrimmed values. - Using
z.number()for query values. They are strings; usez.coerce.number(). - No maximum lengths. A 10 MB "name" can fill your database and logs. Set
.max()on strings and arrays. - Returning only the first error. Return all field errors so users can fix the form in one go.
Key points
- Treat every request body, query string and route parameter as untrusted; validate on the server.
- A schema describes types, formats, ranges and lengths once; Zod's
safeParse()returns either cleandataor anerrorwith all issues. - Zod strips unknown fields from objects, which blocks mass-assignment attacks.
- Use
z.coerce.number()for values from the URL, which always arrive as strings. - A
validate()middleware factory keeps routes short; store results inreq.validbecausereq.queryis read-only in Express 5.
Exercise
Write a Zod schema for a gym membership form: name (2 to 50 characters, trimmed), mobile (10-digit Indian mobile number), plan ("monthly", "quarterly" or "yearly"), startDate (a string in YYYY-MM-DD format) and an optional trainer boolean that defaults to false. Use safeParse() on one valid and one invalid object and print the results with z.flattenError().
Show solution
import { z } from "zod";
const Membership = z.object({
name: z.string().trim().min(2).max(50),
mobile: z.string().regex(/^[6-9]\d{9}$/, "Enter a 10-digit mobile number"),
plan: z.enum(["monthly", "quarterly", "yearly"]),
startDate: z.iso.date("Use the format YYYY-MM-DD"),
trainer: z.boolean().default(false),
});
console.log(Membership.safeParse({
name: " Isha Verma ", mobile: "9812345678", plan: "yearly", startDate: "2026-11-01",
}).data);
const bad = Membership.safeParse({ name: "Isha", mobile: "98123", plan: "weekly", startDate: "01/11/2026" });
console.log(z.flattenError(bad.error).fieldErrors);{
name: 'Isha Verma',
mobile: '9812345678',
plan: 'yearly',
startDate: '2026-11-01',
trainer: false
}
{
mobile: [ 'Enter a 10-digit mobile number' ],
plan: [ 'Invalid option: expected one of "monthly"|"quarterly"|"yearly"' ],
startDate: [ 'Use the format YYYY-MM-DD' ]
}z.iso.date() checks the YYYY-MM-DD format, .default(false) fills in the missing trainer field, and the name is trimmed in the clean data.
Quick Quiz
1. Why must the server validate data even if the HTML form already uses required and type="email"?
Front-end checks are a convenience; the server is the only place you control.
2. What does Zod do with an unknown field such as isAdmin in z.object({...}).safeParse()?
By default Zod objects strip unknown keys, which prevents mass assignment.
3. Which schema accepts the query value "5" and gives the number 5?
Coercion converts the string first; plain z.number() rejects strings.
4. What is the difference between parse() and safeParse()?
Use safeParse() when you want to handle errors without try...catch.
Learn Node.js with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
