Notifications

No notifications

/Phase 3

Input Validation

Never Trust User Input!

Every piece of data from the client must be validated. Users can send anything — typos, empty strings, SQL injection, XSS attacks.

What to Validate

InputChecks
StringsRequired, min/max length, format (email, URL)
NumbersRequired, min/max value, integer check
ArraysRequired, min/max items, item validation
ObjectsRequired fields, no extra fields
IDsValid format (ObjectId, UUID, integer)

Validation Approaches

1. Manual — Write your own checks (simple but verbose) 2. Joi — Most popular schema validation library 3. Zod — TypeScript-first validation 4. express-validator — Middleware-based validation

Example: What Can Go Wrong

// Without validation — DANGEROUS! 💀
app.post('/api/users', (req, res) => {
  db.create(req.body); // User could send ANYTHING
});

// req.body could be: { name: "", email: "not-an-email" } // Bad data { name: "<script>alert('XSS')</script>" } // XSS attack { admin: true, role: "superadmin" } // Privilege escalation

On this page

Detailed Theory

Validation is the bouncer at the door of your API. Before any request touches your business logic or database, you stop and ask: *is this data shaped the way I expect?* If not, you reject it with a clear 400 and a helpful message. Skip this step and you will get crashed servers, corrupted rows, and security holes.

What Validation Actually Is

Validation answers three questions about the incoming request:

1. Are the right fields present? (email is required) 2. Is each field the right type and shape? (age is a positive integer ≤ 120) 3. Do the fields make sense together? (endDate is after startDate)

It happens at the boundary of your system — the moment data arrives from the outside world (HTTP body, query string, URL params, file uploads, even messages from a queue). Once data is past the boundary, the rest of your code can trust it.

Why "Trust Nothing From The Client" Is Not Paranoia

Front-end validation is a UX feature, not a security feature. Anyone can:

  • Open DevTools and edit the form HTML.
  • Send raw requests with curl, Postman, or a script.
  • Replay a captured request with modified fields.
If your server does not re-validate, an attacker can send { "role": "admin", "price": 0 } and your DB will happily save it.

// ❌ Trusting the client
app.post('/orders', async (req, res) => {
  await Order.create(req.body); // What if body has { totalCents: -500 } ?
});

// ✅ Validate first app.post('/orders', validate(orderSchema), async (req, res) => { await Order.create(req.body); // Now safe });

The Three Things You Validate

req.body    // POST/PUT/PATCH JSON body
req.query   // ?search=foo&page=2
req.params  // /users/:id  → { id: '42' } (always strings!)

A common bug: req.params.id is the string '42', but your DB expects a number. Validation should both check *and* coerce the type.

Beginner Mistakes to Skip

1. Validating in the route handler with manual if checks. Works for one field; explodes at five. Use a schema. 2. Returning the first error only. Real users want to fix all problems at once — return *all* validation errors as an array. 3. Vague messages like "Invalid input". Say which field, why, and what was expected. 4. Forgetting to validate query strings. ?limit=10000000 will happily try to load the entire DB into memory. 5. Coercing too aggressively. Number('abc') is NaN, not an error. Reject instead of silently turning bad data into garbage. 6. Mixing validation with business rules. "Email must be unique" is a DB check, not a schema check. Schemas check shape, the service layer checks rules.

Intermediate: Schema Libraries

Three popular choices in Node:

LibraryStrengthTypeScript
ZodTS-first, infers types★★★★★
JoiMature, very expressive★★★
YupFamiliar from Formik★★★★
AJVFastest, JSON Schema standard★★★

For new TypeScript projects: Zod. For pure JS or huge legacy: Joi. For OpenAPI-driven shops: AJV (because it speaks JSON Schema natively).

Intermediate: Zod in 30 Seconds

import { z } from 'zod';

const CreateUser = z.object({ name: z.string().min(2).max(50), email: z.string().email().toLowerCase(), age: z.number().int().min(13).max(120).optional(), role: z.enum(['user', 'admin']).default('user'), password: z.string().min(8), });

type CreateUser = z.infer<typeof CreateUser>; // free TS type

// Use safeParse so you never throw inside a handler const result = CreateUser.safeParse(req.body); if (!result.success) { return res.status(400).json({ errors: result.error.issues }); } await User.create(result.data); // result.data is fully typed

Intermediate: A Reusable validate() Middleware

const validate = (schema, source = 'body') => (req, res, next) => {
  const result = schema.safeParse(req[source]);
  if (!result.success) {
    return res.status(400).json({
      error: {
        code: 'VALIDATION_FAILED',
        details: result.error.issues.map(i => ({
          field: i.path.join('.'),
          message: i.message,
        })),
      },
    });
  }
  req[source] = result.data; // overwrite with parsed/coerced data
  next();
};

app.post('/users', validate(CreateUser), createUserController); app.get('/users', validate(ListQuery, 'query'), listUsersController);

Now every route gets the same error shape for free.

Intermediate: Sanitisation vs Validation

They are different jobs:

  • Validation = reject if wrong ("email must be valid").
  • Sanitisation = clean up what is allowed ("trim spaces", "strip HTML tags").
const sanitizeHtml = require('sanitize-html');
const clean = sanitizeHtml(req.body.bio, { allowedTags: ['b', 'i', 'a'] });

Note: parameterised SQL queries / ODM methods already prevent injection — you do not need to escape SQL by hand.

Advanced: File / Upload Validation

Uploads are the #1 attack surface. Always check:

  • MIME type (do not trust the extension; sniff the magic bytes with file-type).
  • Size limit (multer({ limits: { fileSize: 5 * 1024 * 1024 } })).
  • Allowed types whitelist (never blacklist).
  • Re-encode images through Sharp to strip embedded scripts/EXIF.
  • Store outside the web root or on object storage (S3) with random filenames.

Advanced: Conditional & Cross-Field Rules

Real forms have dependencies. With Zod's refine:

const Booking = z.object({
  startDate: z.coerce.date(),
  endDate:   z.coerce.date(),
  guests:    z.number().int().positive(),
  childCount: z.number().int().nonnegative(),
}).refine(d => d.endDate > d.startDate, {
  message: 'endDate must be after startDate',
  path: ['endDate'],
}).refine(d => d.childCount <= d.guests, {
  message: 'childCount cannot exceed guests',
  path: ['childCount'],
});

Advanced: Stripping Unknown Keys & Defence in Depth

By default Zod drops keys not in the schema (.strip()). For high-security endpoints, use .strict() so unexpected keys *fail* rather than silently disappear — protects against mass-assignment attacks ({ role: 'admin' } snuck into a profile update).

Advanced: One Schema, Many Uses

The big payoff of schema-driven validation:

  • Generate OpenAPI / Swagger docs from the same schema (zod-to-openapi).
  • Generate TypeScript types for the client (z.infer or zod-to-ts).
  • Generate mock data for tests.
  • Validate environment variables at boot (process.env is just another untrusted input).
const Env = z.object({
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
});
export const env = Env.parse(process.env); // crash on boot if misconfigured

Practice Path

1. Build a POST /signup route, validate body with Zod, return all errors at once. 2. Add a reusable validate(schema, source) middleware and apply it to body + query + params. 3. Add file upload with Multer, validate size and real MIME type. 4. Validate process.env at startup so a missing JWT_SECRET crashes the server immediately.