Last 30 Days
No notifications
Every piece of data from the client must be validated. Users can send anything — typos, empty strings, SQL injection, XSS attacks.
| Input | Checks |
| Strings | Required, min/max length, format (email, URL) |
| Numbers | Required, min/max value, integer check |
| Arrays | Required, min/max items, item validation |
| Objects | Required fields, no extra fields |
| IDs | Valid format (ObjectId, UUID, integer) |
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
// 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
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.
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.
Front-end validation is a UX feature, not a security feature. Anyone can:
{ "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
});
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.
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.
Three popular choices in Node:
| Library | Strength | TypeScript |
| Zod | TS-first, infers types | ★★★★★ |
| Joi | Mature, very expressive | ★★★ |
| Yup | Familiar from Formik | ★★★★ |
| AJV | Fastest, 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).
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
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.
They are different jobs:
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.
Uploads are the #1 attack surface. Always check:
file-type).multer({ limits: { fileSize: 5 * 1024 * 1024 } })).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'],
});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).
The big payoff of schema-driven validation:
zod-to-openapi).z.infer or zod-to-ts).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 misconfigured1. 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.