Input Validation
Trust no one - how to validate user input and keep your API safe from bad data.
Never Trust User Input#
Here's a truth that experienced developers learn the hard way: users will send you garbage.
Sometimes it's accidental - a typo, a missing field, a wrong format. Sometimes it's malicious - SQL injection, XSS attacks, buffer overflows.
Either way, if you blindly trust input, bad things happen:
- Database errors crash your server
- Malicious scripts get stored and executed
- Invalid data corrupts your database
- Your API becomes unpredictable
Validation is your first line of defense.
What Is Validation?#
Validation means checking that data meets your expectations before using it:
// Without validation - dangerous
app.post('/users', async (req, res) => {
const user = await User.create(req.body); // What if req.body is garbage?
res.json(user);
});
// With validation - safe
app.post('/users', async (req, res) => {
// Check the data first
const { email, password, name } = req.body;
if (!email || !email.includes('@')) {
return res.status(400).json({ error: 'Invalid email' });
}
if (!password || password.length < 8) {
return res.status(400).json({ error: 'Password too short' });
}
if (!name || name.length > 100) {
return res.status(400).json({ error: 'Invalid name' });
}
const user = await User.create({ email, password, name });
res.json(user);
});
The second version is safer, but also verbose and repetitive. That's why we use validation libraries.
Types of Validation#
1. Presence Validation#
Is the field there?
if (!req.body.email) {
throw new ValidationError('Email is required');
}
2. Type Validation#
Is it the right type?
if (typeof req.body.age !== 'number') {
throw new ValidationError('Age must be a number');
}
3. Format Validation#
Does it match a pattern?
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(req.body.email)) {
throw new ValidationError('Invalid email format');
}
4. Range Validation#
Is it within acceptable limits?
if (req.body.age < 0 || req.body.age > 150) {
throw new ValidationError('Age must be between 0 and 150');
}
5. Business Rule Validation#
Does it make sense in your domain?
if (order.items.length === 0) {
throw new ValidationError('Order must have at least one item');
}
Where to Validate#
Validate at multiple levels:
API Layer (Routes/Middleware)#
Reject bad requests immediately:
// Middleware validates before controller runs
router.post('/users',
validate(createUserSchema), // Validates req.body
userController.create
);
Service Layer#
Business logic validation:
// src/services/orders.js
export async function createOrder(userId, items) {
if (items.length === 0) {
throw new ValidationError('Order must have at least one item');
}
// Check inventory
for (const item of items) {
const product = await Product.findById(item.productId);
if (product.stock < item.quantity) {
throw new ValidationError(`Insufficient stock for ${product.name}`);
}
}
}
Database Layer (Models)#
Last line of defense:
// src/models/User.js
const userSchema = new mongoose.Schema({
email: {
type: String,
required: [true, 'Email is required'],
match: [/^[^\s@]+@[^\s@]+\.[^\s@]+$/, 'Invalid email'],
},
age: {
type: Number,
min: [0, 'Age cannot be negative'],
max: [150, 'Age is too high'],
},
});
Defense in Depth
Validate early (API layer), validate business rules (service layer), validate data integrity (database). Each layer catches different types of problems.
Building a Validation Middleware#
Here's a simple validation middleware pattern:
// src/middleware/validate.js
import { ValidationError } from '../utils/errors.js';
export function validate(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
const errors = result.error.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message,
}));
throw new ValidationError('Validation failed', errors);
}
// Replace body with validated (and transformed) data
req.body = result.data;
next();
};
}
Usage:
// src/routes/users.js
import { validate } from '../middleware/validate.js';
import { createUserSchema } from '../validators/users.js';
router.post('/',
validate(createUserSchema),
userController.create
);
Common Validation Patterns#
Required Fields#
const schema = {
email: { required: true },
password: { required: true },
name: { required: true },
};
Optional with Default#
const schema = {
role: { default: 'user' },
isActive: { default: true },
};
Conditional Validation#
// If shipping address is provided, all fields are required
if (data.shippingAddress) {
if (!data.shippingAddress.street) {
throw new ValidationError('Street is required for shipping');
}
if (!data.shippingAddress.city) {
throw new ValidationError('City is required for shipping');
}
}
Sanitization#
Clean data while validating:
const sanitized = {
email: data.email.toLowerCase().trim(),
name: data.name.trim(),
bio: sanitizeHtml(data.bio), // Remove dangerous HTML
};
Error Response Format#
Return helpful error messages:
// Bad - unclear what's wrong
{ "error": "Invalid input" }
// Good - tells you exactly what to fix
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": [
{ "field": "email", "message": "Invalid email format" },
{ "field": "password", "message": "Must be at least 8 characters" },
{ "field": "age", "message": "Must be a positive number" }
]
}
}
Security Considerations#
Prevent Injection#
// SQL injection (if using SQL)
const name = req.body.name; // Could be: "'; DROP TABLE users; --"
// Use parameterized queries, never string concatenation
// NoSQL injection (MongoDB)
const query = req.body.query; // Could be: { "$gt": "" }
// Validate types strictly, don't pass objects directly
Limit Size#
// Prevent large payloads
app.use(express.json({ limit: '10kb' }));
// Validate array lengths
if (items.length > 100) {
throw new ValidationError('Maximum 100 items allowed');
}
// Validate string lengths
if (bio.length > 5000) {
throw new ValidationError('Bio too long');
}
Whitelist, Don't Blacklist#
// Bad - trying to block bad things
const forbidden = ['<script>', 'javascript:', 'onerror'];
// Easy to bypass: <SCRIPT>, java\nscript:, etc.
// Good - only allow known good things
const allowed = ['user', 'admin'];
if (!allowed.includes(role)) {
throw new ValidationError('Invalid role');
}
Manual Validation Example#
Without a library:
// src/validators/users.js
import { ValidationError } from '../utils/errors.js';
export function validateCreateUser(data) {
const errors = [];
// Email
if (!data.email) {
errors.push({ field: 'email', message: 'Email is required' });
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
errors.push({ field: 'email', message: 'Invalid email format' });
}
// Password
if (!data.password) {
errors.push({ field: 'password', message: 'Password is required' });
} else if (data.password.length < 8) {
errors.push({ field: 'password', message: 'Password must be at least 8 characters' });
}
// Name
if (!data.name) {
errors.push({ field: 'name', message: 'Name is required' });
} else if (data.name.length > 100) {
errors.push({ field: 'name', message: 'Name must be 100 characters or less' });
}
if (errors.length > 0) {
throw new ValidationError('Validation failed', errors);
}
// Return sanitized data
return {
email: data.email.toLowerCase().trim(),
password: data.password,
name: data.name.trim(),
};
}
This works, but it's verbose. That's why libraries like Zod exist.
Key Takeaways#
- Never trust user input - Always validate
- Validate early - Reject bad requests at the API layer
- Be specific with errors - Tell users what's wrong and how to fix it
- Sanitize while validating - Trim, lowercase, clean
- Whitelist, don't blacklist - Define what's allowed, not what's forbidden
- Defense in depth - Validate at multiple layers
The Cardinal Rule
Treat every piece of user input as potentially malicious until proven otherwise. Validation isn't paranoia - it's professional engineering.
Ready to level up your skills?
Explore more guides and tutorials to deepen your understanding and become a better developer.