Home/Blog/Cybersecurity/API Input Validation: Schema Validation, Sanitization, and Injection Prevention
Cybersecurity

API Input Validation: Schema Validation, Sanitization, and Injection Prevention

Protect your APIs from injection attacks and malformed data with proper input validation. Covers JSON Schema, OpenAPI validation, and sanitization best practices.

By Inventive HQ Team
API Input Validation: Schema Validation, Sanitization, and Injection Prevention

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

  1. Validate at every layer - Gateway, controller, service, repository
  2. Prefer allowlists - Define what IS allowed, reject everything else
  3. Use parameterized queries - Never concatenate input into queries
  4. Define strict schemas - Type, format, length, range constraints
  5. Block additional properties - Reject unknown fields
  6. Use DTOs - Explicitly map allowed fields
  7. Validate file content - Magic bytes, not extensions
  8. Enforce Content-Type - Reject unexpected content types
  9. Return helpful errors - For legitimate users, not attackers
  10. Test validation - Include invalid input in test suites

Next Steps

Need Expert Cybersecurity Guidance?

Our team of security experts is ready to help protect your business from evolving threats.