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

Frequently Asked Questions

Find answers to common questions

Validation checks if input meets expected criteria (type, format, length, range) and rejects invalid input. Sanitization modifies input to remove or encode dangerous content while preserving valid data. Use both: validate first to reject obviously bad input, then sanitize what passes to handle edge cases. Never sanitize without validating—attackers craft inputs specifically to bypass sanitization.

Always validate on the server—client validation is for UX only and can be bypassed. Server-side validation is your security boundary. Client-side validation improves user experience by providing immediate feedback and reducing server load for obviously invalid requests, but never trust it for security. Attackers can call your API directly, bypassing any client-side checks.

JSON Schema is a vocabulary for annotating and validating JSON documents. It defines expected structure, types, formats, and constraints for API request/response bodies. Security benefits include automatic type enforcement, length/range limits, format validation (email, URI, date), required field checks, and pattern matching. Tools like Ajv (JavaScript), jsonschema (Python) validate against schemas automatically.

Use parameterized queries or prepared statements—never concatenate user input into SQL strings. ORMs like Prisma, TypeORM, or SQLAlchemy use parameterization by default. Additionally, validate input types and formats, apply principle of least privilege to database accounts, and use stored procedures where appropriate. Allowlist expected characters when possible.

MongoDB is vulnerable when query operators come from user input. Never pass raw user input to query methods. Validate that input is the expected type (string vs object), use schema validation (Mongoose schemas), sanitize with mongo-sanitize or similar, and avoid $where and mapReduce with user input. Attackers send objects like {"$gt": ""} instead of strings to bypass authentication.

Mass assignment occurs when an API blindly accepts all fields from input and assigns them to a model, allowing attackers to set fields they shouldn't (isAdmin, balance, role). Prevent it by explicitly allowlisting fields that can be set from input, using DTOs (Data Transfer Objects) that only contain allowed fields, and never passing raw request bodies directly to database operations.

Validate file uploads by checking file size limits before processing, validating MIME type via magic bytes (not just extension or Content-Type header), using an allowlist of permitted file types, scanning for malware, generating new filenames (never use user-provided names), storing outside web root, and serving via a separate domain. Never execute or directly serve uploaded files.

Strictly enforce Content-Type headers—reject requests with unexpected content types. For JSON APIs, require application/json and reject form-encoded data if not needed. Validate the body actually matches the declared Content-Type. This prevents content-type confusion attacks where attackers exploit different parsing behaviors. Return 415 Unsupported Media Type for wrong content types.

Return 400 Bad Request with structured error details including field name, received value (sanitized), expected format, and error message. Be specific enough to help legitimate users but don't leak internal implementation details. Use consistent error format across all endpoints. Rate limit error responses to prevent enumeration attacks that probe for valid values.

Always prefer allowlists (whitelists) over blocklists (blacklists). Allowlists define what IS permitted—anything else is rejected. Blocklists define what ISN'T permitted—anything else is allowed, which misses new attack patterns. Attackers constantly find ways around blocklists. Allowlist expected characters, formats, values, and lengths. Only use blocklists as defense-in-depth, never as primary protection.

Don't wait for a breach to act

Get a free security assessment. Our experts will identify your vulnerabilities and create a protection plan tailored to your business.