Validating input

Express · Practical · 13 min

Unvalidated input causes crashes, data corruption and security holes; validation at the edge prevents all three.

Everything in req.params, req.query and req.body is untrusted and loosely typed. Validate it at the boundary, in the route, before it reaches business logic. Hand-written if checks don't scale; use a schema library. Zod is popular in the TypeScript world; Joi, Yup, Valibot, AJV (JSON Schema) and express-validator are common alternatives.

A schema does three jobs at once:

  • Validates shape and constraints (types, lengths, ranges, formats).
  • Coerces/transforms — query strings are strings, so z.coerce.number() turns "20" into 20; defaults fill missing fields.
  • Strips unknown keys — Zod's z.object drops fields you didn't declare, preventing mass-assignment bugs like a client sending "role": "admin".

Use safeParse and turn failures into a 400 Bad Request with field-level messages so clients can fix their request. Never pass the raw req.body to your database layer; pass the parsed result.

A small reusable middleware keeps routes tidy: validate({ body: schema }) parses and stores the result. In Express 5 req.query is a read-only getter, so don't try to overwrite it; store the parsed values elsewhere, such as res.locals or a property like req.valid.

What belongs where:

  • Schema validation (shape, format) at the edge.
  • Business rules (email already registered, insufficient balance) in services — usually 409 or 422.
  • Database constraints as a last line of defence.

Also validate path params — an id that isn't a number or UUID should be a 400 or 404, not a database error.

Example

import express from "express";
import { z } from "zod";

const app = express();
app.use(express.json());

const validate = (schemas) => (req, res, next) => {
  req.valid = {};
  for (const [part, schema] of Object.entries(schemas)) {
    const result = schema.safeParse(req[part] ?? {});
    if (!result.success) {
      return res.status(400).json({ error: "validation failed", part, issues: z.flattenError(result.error).fieldErrors });
    }
    req.valid[part] = result.data;
  }
  next();
};

const createUser = z.object({
  email: z.email(),
  name: z.string().trim().min(1).max(100),
  age: z.number().int().min(13).optional(),
});                                       // unknown keys (e.g. "role") are stripped
const listQuery = z.object({ page: z.coerce.number().int().min(1).default(1) });

app.post("/users", validate({ body: createUser }), (req, res) => res.status(201).json(req.valid.body));
app.get("/users", validate({ query: listQuery }), (req, res) => res.json({ page: req.valid.query.page }));

app.listen(3000);

Remember

  • Validate params, query and body at the route boundary with a schema
  • Schemas also coerce types and strip unknown fields
  • Return 400 with field-level details; pass only parsed data onward
  • Express 5's req.query is read-only — store parsed values elsewhere

Watch out for

  • Spreading req.body straight into a database update (mass assignment).

Check yourself

  1. Why is z.coerce.number() typically used for query parameters rather than z.number()?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: expressjs.com/en/api/request