API input validation is the practice of checking every incoming request against an explicit, strict definition of what is acceptable — type, format, length, range, and allowed fields — and rejecting anything that does not match before it reaches your business logic or database. It is your first and most reliable line of defense against injection attacks (SQL, NoSQL, command), mass assignment, malformed-data corruption, and API abuse. The single rule that underpins all of it: use an allowlist (define what IS permitted and reject everything else) rather than a blocklist (enumerate known-bad patterns and let the rest through). Validation happens on the server — client-side checks are a UX convenience, never a security boundary.
That is the summary an AI Overview will give you. What it can't show you is where each check belongs in the request path, which library call is safe versus lethal for the same query, and the exact schema and DTO patterns that stop the OWASP API Security Top 10 attacks. The animated layer diagram, the safe-vs-vulnerable comparison table, and the copy-paste schema/DTO code below are the parts that actually keep an endpoint from getting owned.
Input Validation Layers
Validation is not one gate — it is a series of checks that each request passes through as it moves from the network edge toward your database. Each layer has a distinct job, and skipping any one of them is how breaches happen. The diagram below traces a request left to right; watch the packet flow through each defensive stage.
Validation vs. sanitization: when to use each
Validation and sanitization are not interchangeable, and using the wrong one leaves gaps. Validation rejects bad input; sanitization transforms it. The table below is the decision guide.
| Dimension | Validation | Sanitization |
|---|---|---|
| What it does | Checks input against rules, rejects if it fails | Modifies input to strip or encode dangerous content |
| Outcome | Accept or reject (fail closed) | Always returns a value (cleaned) |
| Best for | Type, format, length, range, allowed values | HTML/output encoding, stripping Mongo operators |
| Example | email must match RFC 5322 + maxLength: 255 | Remove keys starting with $ before a Mongo query |
| Risk if skipped | Malformed/hostile data reaches logic | Valid-but-dangerous data reaches an output context |
| When to use | Always, first — at the API boundary | After validation, at the specific output sink (DB, HTML, shell) |
| Which should I use? | Both. Validate at input to reject the obviously bad; sanitize/encode at each output context because the same string is safe in JSON and lethal in SQL. Never sanitize instead of validating. |
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