Zod at the Boundary — Trust Inside
Validation
Validate request bodies, params, and query at the edge. Zod is the standard tool in modern Node.
What you'll learn
- Define Zod schemas
- Reuse a validate middleware
- Return clean validation errors
Untrusted input is the root of most vulnerabilities. Validate at the boundary — before any business logic touches the data.
Install Zod
npm install zod A Schema
import { z } from "zod";
export const CreateUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(72),
name: z.string().min(1).max(60),
age: z.number().int().min(13).max(120).optional(),
}); A Validate Middleware
// src/middleware/validate.js
export function validate({ body, params, query }) {
return (req, res, next) => {
if (body) {
const r = body.safeParse(req.body);
if (!r.success) return badRequest(res, r.error);
req.validBody = r.data;
}
if (params) {
const r = params.safeParse(req.params);
if (!r.success) return badRequest(res, r.error);
req.validParams = r.data;
}
if (query) {
const r = query.safeParse(req.query);
if (!r.success) return badRequest(res, r.error);
req.validQuery = r.data;
}
next();
};
}
function badRequest(res, error) {
res.status(400).json({
error: {
code: "validation_failed",
issues: error.issues.map((i) => ({
path: i.path.join("."),
message: i.message,
})),
},
});
} Use It
import { validate } from "../middleware/validate.js";
import { CreateUserSchema } from "../schemas/users.js";
router.post("/", validate({ body: CreateUserSchema }), userController.create); Inside userController.create, req.validBody is fully typed and
guaranteed valid.
Validate Params + Query
import { z } from "zod";
const IdParam = z.object({ id: z.coerce.number().int().positive() });
const ListQuery = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
router.get("/",
validate({ query: ListQuery }),
userController.list
);
router.get("/:id",
validate({ params: IdParam }),
userController.get
); z.coerce turns the string "42" into the number 42. Magic.
Error Format
The standard issue shape after our badRequest helper:
{
"error": {
"code": "validation_failed",
"issues": [
{ "path": "email", "message": "Invalid email" },
{ "path": "password", "message": "String must contain at least 8 character(s)" }
]
}
} Frontends can render each issue next to its form field.
Why Not Just TypeScript?
TS types vanish at compile time. An attacker sending arbitrary JSON slips past TS. Zod (or any runtime schema) is the only thing that catches them at runtime.
In practice: TS types derived from Zod schemas — one source of truth:
type CreateUser = z.infer<typeof CreateUserSchema>;