Input validation is your first line of defense against injection attacks, data corruption, and API abuse. This guide covers validation strategies, schema enforcement, and sanitization techniques for secure APIs.
Input Validation Layers
┌─────────────────────────────────────────────────────────────────────────────┐
│ INPUT VALIDATION ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Request ──► Gateway ──► Controller ──► Service ──► Repository ──► DB │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Rate │ │ Schema │ │ Business│ │ Query │ │
│ │ Limits │ │Validate │ │ Rules │ │ Params │ │
│ │ Size │ │ Type │ │ Logic │ │ Escape │ │
│ │ Headers │ │ Format │ │ Context │ │ Prepare │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
│ │
│ VALIDATION PRINCIPLES: │
│ ✓ Validate at every layer (defense in depth) │
│ ✓ Allowlist over blocklist │
│ ✓ Fail closed (reject on validation failure) │
│ ✓ Validate before use, sanitize for output context │
│ ✓ Never trust client-side validation │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
JSON Schema Validation
Define Request Schemas
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://api.example.com/schemas/create-user.json",
"type": "object",
"required": ["email", "name", "password"],
"additionalProperties": false,
"properties": {
"email": {
"type": "string",
"format": "email",
"maxLength": 255
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 100,
"pattern": "^[a-zA-Z\\s\\-']+$"
},
"password": {
"type": "string",
"minLength": 12,
"maxLength": 128
},
"age": {
"type": "integer",
"minimum": 13,
"maximum": 150
},
"role": {
"type": "string",
"enum": ["user", "moderator"],
"default": "user"
},
"preferences": {
"type": "object",
"additionalProperties": false,
"properties": {
"newsletter": { "type": "boolean" },
"theme": { "enum": ["light", "dark", "auto"] }
}
}
}
}
Validation with Ajv (JavaScript)
import Ajv, { JSONSchemaType } from 'ajv';
import addFormats from 'ajv-formats';
interface CreateUserRequest {
email: string;
name: string;
password: string;
age?: number;
role?: 'user' | 'moderator';
}
const ajv = new Ajv({ allErrors: true, removeAdditional: true });
addFormats(ajv);
const schema: JSONSchemaType<CreateUserRequest> = {
type: 'object',
required: ['email', 'name', 'password'],
additionalProperties: false,
properties: {
email: { type: 'string', format: 'email', maxLength: 255 },
name: { type: 'string', minLength: 1, maxLength: 100 },
password: { type: 'string', minLength: 12, maxLength: 128 },
age: { type: 'integer', minimum: 13, maximum: 150, nullable: true },
role: { type: 'string', enum: ['user', 'moderator'], nullable: true },
},
};
const validate = ajv.compile(schema);
// Express middleware
function validateBody<T>(schema: JSONSchemaType<T>) {
const validate = ajv.compile(schema);
return (req: Request, res: Response, next: NextFunction) => {
if (!validate(req.body)) {
return res.status(400).json({
error: 'Validation failed',
details: validate.errors?.map(err => ({
field: err.instancePath || err.params.missingProperty,
message: err.message,
received: err.data,
})),
});
}
next();
};
}
// Usage
app.post('/users', validateBody(schema), createUser);
OpenAPI/Swagger Validation
# openapi.yaml
openapi: 3.1.0
info:
title: User API
version: 1.0.0
paths:
/users:
post:
summary: Create user
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateUser'
responses:
'201':
description: User created
'400':
description: Validation error
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
components:
schemas:
CreateUser:
type: object
required:
- email
- name
- password
additionalProperties: false
properties:
email:
type: string
format: email
maxLength: 255
name:
type: string
minLength: 1
maxLength: 100
pattern: ^[a-zA-Z\s\-']+$
password:
type: string
minLength: 12
maxLength: 128
writeOnly: true
ValidationError:
type: object
properties:
error:
type: string
details:
type: array
items:
type: object
properties:
field:
type: string
message:
type: string
// Express middleware with OpenAPI validation
import { OpenAPIValidator } from 'express-openapi-validator';
app.use(
OpenAPIValidator.middleware({
apiSpec: './openapi.yaml',
validateRequests: true,
validateResponses: true,
})
);
SQL Injection Prevention
Parameterized Queries
// ❌ VULNERABLE - String concatenation
const query = `SELECT * FROM users WHERE email = '${email}'`;
// ✅ SAFE - Parameterized query
// Node.js with pg
const result = await pool.query(
'SELECT * FROM users WHERE email = $1',
[email]
);
// Node.js with mysql2
const [rows] = await connection.execute(
'SELECT * FROM users WHERE email = ?',
[email]
);
// Python with psycopg2
cursor.execute(
"SELECT * FROM users WHERE email = %s",
(email,)
)
ORM Safe Patterns
// Prisma (safe by default)
const user = await prisma.user.findUnique({
where: { email: email },
});
// TypeORM - Safe
const user = await userRepository.findOne({
where: { email: email },
});
// ❌ TypeORM - UNSAFE raw query
const users = await userRepository.query(
`SELECT * FROM users WHERE name LIKE '%${search}%'`
);
// ✅ TypeORM - Safe raw query
const users = await userRepository.query(
'SELECT * FROM users WHERE name LIKE $1',
[`%${search}%`]
);
Dynamic Query Building (Safe)
import { Prisma } from '@prisma/client';
interface UserFilters {
name?: string;
role?: string;
minAge?: number;
}
function buildUserQuery(filters: UserFilters): Prisma.UserWhereInput {
const where: Prisma.UserWhereInput = {};
if (filters.name) {
// Prisma handles escaping
where.name = { contains: filters.name, mode: 'insensitive' };
}
if (filters.role) {
// Validate against allowed values
const allowedRoles = ['user', 'admin', 'moderator'];
if (allowedRoles.includes(filters.role)) {
where.role = filters.role;
}
}
if (filters.minAge !== undefined) {
// Ensure it's a number
const age = parseInt(String(filters.minAge), 10);
if (!isNaN(age) && age >= 0 && age <= 150) {
where.age = { gte: age };
}
}
return where;
}
const users = await prisma.user.findMany({
where: buildUserQuery(req.query),
});
NoSQL Injection Prevention
MongoDB Injection Vectors
// ❌ VULNERABLE - Direct input in query
// Attacker sends: { "username": { "$gt": "" }, "password": { "$gt": "" } }
const user = await User.findOne({
username: req.body.username,
password: req.body.password,
});
// This matches ANY user!
// ✅ SAFE - Type validation
const { username, password } = req.body;
if (typeof username !== 'string' || typeof password !== 'string') {
return res.status(400).json({ error: 'Invalid input type' });
}
const user = await User.findOne({
username: username,
password: password, // Should be hashed comparison
});
MongoDB Sanitization
import mongoSanitize from 'express-mongo-sanitize';
// Middleware to remove $ and . from input
app.use(mongoSanitize());
// Or manual sanitization
function sanitizeMongoInput(input: unknown): unknown {
if (typeof input === 'object' && input !== null) {
if (Array.isArray(input)) {
return input.map(sanitizeMongoInput);
}
const sanitized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(input)) {
// Remove keys starting with $ or containing .
if (!key.startsWith('$') && !key.includes('.')) {
sanitized[key] = sanitizeMongoInput(value);
}
}
return sanitized;
}
return input;
}
// Mongoose schema validation
const userSchema = new mongoose.Schema({
email: {
type: String,
required: true,
validate: {
validator: (v: string) => /^[\w\-.]+@[\w\-.]+\.\w+$/.test(v),
message: 'Invalid email format',
},
},
role: {
type: String,
enum: ['user', 'admin'],
default: 'user',
},
});
Mass Assignment Prevention
Vulnerable Pattern
// ❌ VULNERABLE - All fields from request body
app.put('/users/:id', async (req, res) => {
await User.update(req.params.id, req.body);
// Attacker can set { role: 'admin', isVerified: true }
});
DTO Pattern
// Define allowed fields explicitly
interface UpdateUserDTO {
name?: string;
email?: string;
preferences?: {
newsletter?: boolean;
theme?: 'light' | 'dark';
};
}
function toUpdateUserDTO(input: unknown): UpdateUserDTO {
const dto: UpdateUserDTO = {};
const body = input as Record<string, unknown>;
if (typeof body.name === 'string') {
dto.name = body.name.slice(0, 100);
}
if (typeof body.email === 'string') {
dto.email = body.email.toLowerCase();
}
if (typeof body.preferences === 'object' && body.preferences) {
dto.preferences = {};
const prefs = body.preferences as Record<string, unknown>;
if (typeof prefs.newsletter === 'boolean') {
dto.preferences.newsletter = prefs.newsletter;
}
if (prefs.theme === 'light' || prefs.theme === 'dark') {
dto.preferences.theme = prefs.theme;
}
}
return dto;
}
app.put('/users/:id', async (req, res) => {
const dto = toUpdateUserDTO(req.body);
await prisma.user.update({
where: { id: req.params.id },
data: dto,
});
});
Class-validator (NestJS)
import {
IsEmail,
IsString,
MinLength,
MaxLength,
IsOptional,
IsEnum,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
class PreferencesDTO {
@IsOptional()
@IsBoolean()
newsletter?: boolean;
@IsOptional()
@IsEnum(['light', 'dark'])
theme?: 'light' | 'dark';
}
class UpdateUserDTO {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(100)
name?: string;
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@ValidateNested()
@Type(() => PreferencesDTO)
preferences?: PreferencesDTO;
// These fields are NOT in the DTO, so they can't be set
// role, isAdmin, balance, etc.
}
@Controller('users')
export class UsersController {
@Put(':id')
update(
@Param('id') id: string,
@Body() updateUserDto: UpdateUserDTO, // Auto-validated
) {
return this.usersService.update(id, updateUserDto);
}
}
File Upload Validation
import multer from 'multer';
import { fileTypeFromBuffer } from 'file-type';
import crypto from 'crypto';
import path from 'path';
// Configure multer
const upload = multer({
limits: {
fileSize: 5 * 1024 * 1024, // 5MB
files: 1,
},
storage: multer.memoryStorage(),
});
// Allowed types (by magic bytes, not extension)
const ALLOWED_TYPES = new Map([
['image/jpeg', ['.jpg', '.jpeg']],
['image/png', ['.png']],
['image/gif', ['.gif']],
['application/pdf', ['.pdf']],
]);
async function validateUpload(
file: Express.Multer.File
): Promise<{ valid: boolean; error?: string; mimeType?: string }> {
// Check file exists
if (!file || !file.buffer) {
return { valid: false, error: 'No file provided' };
}
// Detect actual file type from magic bytes
const detected = await fileTypeFromBuffer(file.buffer);
if (!detected) {
return { valid: false, error: 'Unable to determine file type' };
}
// Check against allowlist
if (!ALLOWED_TYPES.has(detected.mime)) {
return { valid: false, error: `File type ${detected.mime} not allowed` };
}
// Verify extension matches content
const ext = path.extname(file.originalname).toLowerCase();
const allowedExts = ALLOWED_TYPES.get(detected.mime);
if (!allowedExts?.includes(ext)) {
return { valid: false, error: 'File extension does not match content' };
}
return { valid: true, mimeType: detected.mime };
}
function generateSafeFilename(mimeType: string): string {
const ext = ALLOWED_TYPES.get(mimeType)?.[0] || '';
const randomName = crypto.randomBytes(16).toString('hex');
return `${randomName}${ext}`;
}
app.post('/upload', upload.single('file'), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const validation = await validateUpload(req.file);
if (!validation.valid) {
return res.status(400).json({ error: validation.error });
}
// Generate safe filename
const filename = generateSafeFilename(validation.mimeType!);
// Store outside web root
const storagePath = path.join('/var/uploads', filename);
await fs.writeFile(storagePath, req.file.buffer);
// Return reference, not direct URL
res.json({ fileId: filename });
});
Content-Type Enforcement
// Middleware to enforce Content-Type
function requireContentType(allowedTypes: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
// Skip for methods without body
if (['GET', 'HEAD', 'DELETE'].includes(req.method)) {
return next();
}
const contentType = req.headers['content-type']?.split(';')[0];
if (!contentType || !allowedTypes.includes(contentType)) {
return res.status(415).json({
error: 'Unsupported Media Type',
message: `Content-Type must be one of: ${allowedTypes.join(', ')}`,
});
}
next();
};
}
// Usage
app.use('/api', requireContentType(['application/json']));
// For specific routes that accept form data
app.post(
'/upload',
requireContentType(['multipart/form-data']),
uploadHandler
);
Validation Error Responses
interface ValidationErrorDetail {
field: string;
message: string;
code: string;
received?: unknown;
}
interface ValidationErrorResponse {
error: 'VALIDATION_ERROR';
message: string;
details: ValidationErrorDetail[];
requestId: string;
}
function formatValidationErrors(
errors: Ajv.ErrorObject[],
requestId: string
): ValidationErrorResponse {
return {
error: 'VALIDATION_ERROR',
message: 'Request validation failed',
requestId,
details: errors.map(err => ({
field: err.instancePath.replace(/^\//, '') || err.params.missingProperty,
message: err.message || 'Invalid value',
code: err.keyword.toUpperCase(),
// Sanitize received value - don't expose full input
received: sanitizeForError(err.data),
})),
};
}
function sanitizeForError(value: unknown): unknown {
if (typeof value === 'string') {
// Truncate long strings
return value.length > 50 ? `${value.slice(0, 50)}...` : value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return value;
}
if (Array.isArray(value)) {
return `Array(${value.length})`;
}
if (typeof value === 'object') {
return '[Object]';
}
return typeof value;
}
Security Checklist
┌─────────────────────────────────────────────────────────────────────────────┐
│ INPUT VALIDATION CHECKLIST │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ SCHEMA VALIDATION │
│ [ ] All endpoints have defined request schemas │
│ [ ] additionalProperties: false to reject unknown fields │
│ [ ] Required fields explicitly listed │
│ [ ] String length limits (minLength, maxLength) │
│ [ ] Number ranges (minimum, maximum) │
│ [ ] Format validation (email, URI, date, uuid) │
│ [ ] Enum for constrained values │
│ │
│ INJECTION PREVENTION │
│ [ ] Parameterized queries for all database operations │
│ [ ] ORM used with safe query patterns │
│ [ ] NoSQL operator injection prevented │
│ [ ] Command injection prevented (no shell execution) │
│ [ ] Path traversal prevented │
│ │
│ MASS ASSIGNMENT │
│ [ ] DTOs define allowed fields explicitly │
│ [ ] Sensitive fields never assignable from input │
│ [ ] Object mapping uses allowlist │
│ │
│ FILE UPLOADS │
│ [ ] Size limits enforced │
│ [ ] File type validated by magic bytes │
│ [ ] Filename generated server-side │
│ [ ] Files stored outside web root │
│ [ ] Malware scanning enabled │
│ │
│ CONTENT TYPE │
│ [ ] Content-Type header required and validated │
│ [ ] Body parsing matches declared content type │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Best Practices
- Validate at every layer - Gateway, controller, service, repository
- Prefer allowlists - Define what IS allowed, reject everything else
- Use parameterized queries - Never concatenate input into queries
- Define strict schemas - Type, format, length, range constraints
- Block additional properties - Reject unknown fields
- Use DTOs - Explicitly map allowed fields
- Validate file content - Magic bytes, not extensions
- Enforce Content-Type - Reject unexpected content types
- Return helpful errors - For legitimate users, not attackers
- Test validation - Include invalid input in test suites
Next Steps
- API Security Complete Guide - Comprehensive security overview
- GraphQL Security - GraphQL-specific validation
- API Penetration Testing - Test your validation
- API Gateway Security - Gateway-level validation