Validation Middleware
Objectives
By the end of this lesson, you should be able to:
- Build reusable Express middleware that validates a request body against a Zod schema
- Return structured, per-field validation errors to a client
- Confirm validation middleware closes the mass-assignment gap from Lesson 1
💡 Why this matters: Lesson 2 validated data by hand, in a script. This lesson wires that same validation into Express as middleware, checked automatically before a route handler ever runs, exactly where it needs to live in a real API.
⚠️ A note on verification: every snippet and every output in this lesson was actually run, with real HTTP requests against a real Express app.
A Reusable validate Middleware
function validate(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'ValidationError',
message: 'Invalid request data',
details: result.error.issues.map(i => ({ field: i.path.join('.'), message: i.message }))
});
}
req.validatedBody = result.data;
next();
};
}
validate(schema) is a middleware factory, the same pattern requireRole(role) used in Module 4, it returns middleware configured for one specific schema, reusable for every route in an application, each with its own schema. On success, the validated, stripped data is attached to req.validatedBody, never the raw, untrusted req.body directly, that distinction matters, req.validatedBody is guaranteed to match the schema exactly, extra fields and all.
Using It on a Route
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(8, 'Password must be at least 8 characters')
});
app.post('/api/v1/auth/register', validate(registerSchema), (req, res) => {
res.status(201).json({ email: req.validatedBody.email });
});
validate(registerSchema) runs before the route handler, exactly like requireAuth (Module 3) or requireRole (Module 4), if validation fails, the handler never runs at all.
Testing a Valid Request
const good = await request(app).post('/api/v1/auth/register').send({ email: 'erin@example.com', password: 'sunshine123' });
console.log(good.status, good.body);
201 { email: 'erin@example.com' }
Testing an Invalid Request
const bad = await request(app).post('/api/v1/auth/register').send({ email: 'not-an-email', password: 'short' });
console.log(bad.status, bad.body);
400 {
error: 'ValidationError',
message: 'Invalid request data',
details: [
{ field: 'email', message: 'Invalid email address' },
{ field: 'password', message: 'Password must be at least 8 characters' }
]
}
A client gets back exactly which fields were wrong, and why, in a consistent, structured shape, every validation failure in the application returns this exact same response structure, just with different details.
Confirming Mass Assignment Is Actually Closed
const massAssign = await request(app).post('/api/v1/auth/register').send({ email: 'jordan@example.com', password: 'sunshine123', role: 'admin' });
console.log(massAssign.status, massAssign.body);
201 { email: 'jordan@example.com' }
The request succeeded, role: 'admin' was in the request body, but the response, built from req.validatedBody, not req.body, has no trace of it, silently stripped by Zod (Lesson 2), exactly the protection Lesson 1’s mass-assignment example needed. This only works because the route reads from req.validatedBody, a route that still reads req.body directly, even with validate() in place, would remain vulnerable, the middleware validates and strips, but only helps if the rest of the code actually uses its output.
Try It
- Build the
validatemiddleware and the registration route, and confirm all three responses shown above. - Try sending a request with a completely empty body, and confirm the
detailsarray lists both missing fields. - Build a second schema and route (for example, a login schema requiring just
emailand a non-emptypassword), reusing the samevalidatemiddleware factory. - Deliberately change the registration route to use
req.body.roleinstead ofreq.validatedBody, resend the mass-assignment attempt from above, and confirm the vulnerability returns, then change it back.
Recap
validate(schema)is reusable middleware, rejecting a request with a structured400before its handler ever runs.- Every validation failure returns the same consistent shape,
error,message, and adetailsarray naming each failing field. - Mass-assignment protection only works end to end if route handlers read from the validated, stripped
req.validatedBody, not the rawreq.body.
This is the final lesson of this module before exercises. Next module: security hardening, rate limiting and defense in depth on top of the authentication, authorization, and validation built so far.