CodingNic

Input Validation

Validation Middleware

Input Validation 15 min read

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

javascript
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

javascript
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

javascript
const good = await request(app).post('/api/v1/auth/register').send({ email: 'erin@example.com', password: 'sunshine123' });
console.log(good.status, good.body);
text
201 { email: 'erin@example.com' }

Testing an Invalid Request

javascript
const bad = await request(app).post('/api/v1/auth/register').send({ email: 'not-an-email', password: 'short' });
console.log(bad.status, bad.body);
text
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

javascript
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);
text
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

  1. Build the validate middleware and the registration route, and confirm all three responses shown above.
  2. Try sending a request with a completely empty body, and confirm the details array lists both missing fields.
  3. Build a second schema and route (for example, a login schema requiring just email and a non-empty password), reusing the same validate middleware factory.
  4. Deliberately change the registration route to use req.body.role instead of req.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 structured 400 before its handler ever runs.
  • Every validation failure returns the same consistent shape, error, message, and a details array naming each failing field.
  • Mass-assignment protection only works end to end if route handlers read from the validated, stripped req.validatedBody, not the raw req.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.