Validating Input

Node.js · Lesson 18 of 30 7 min read Updated

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 role are 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 into 2.

Hand-written checks

For one or two fields, plain code is fine:

JavaScript
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).

JavaScript
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}`);
}
Output
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 number

What happened:

  • safeParse() never throws. It returns { success: true, data } or { success: false, error }. (parse() returns the data directly and throws a ZodError on failure.)
  • data is a clean copy: the spaces around the name were trimmed, and the unknown isAdmin field was removed. Always use result.data, never the original req.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 a message. You can pass your own message as the last argument of most checks.
  • .optional() allows the field to be missing; phone was present but wrong, so it failed.
Zod pieceChecks
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:

JavaScript
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);
Output
{ page: 3, limit: 10 }
Invalid input: expected number, received NaN

A 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:

JavaScript
// 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:

JavaScript
// 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:

Output
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.body after validating. Use the cleaned result.data; the raw body still contains unknown fields and untrimmed values.
  • Using z.number() for query values. They are strings; use z.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 clean data or an error with 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 in req.valid because req.query is 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
JavaScript
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);
Output
{
  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"?

2. What does Zod do with an unknown field such as isAdmin in z.object({...}).safeParse()?

3. Which schema accepts the query value "5" and gives the number 5?

4. What is the difference between parse() and safeParse()?

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