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 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.

API input validation defense-in-depth layers A request travels through Gateway, Controller, Service, and Repository layers, each applying a distinct validation check before reaching the database. Input Validation: Defense in Depth Each layer rejects what the layer before it could not know was invalid Request DB Gateway Rate limits Body size caps Header checks Stops floods Controller Schema validate Type + format Reject extras Stops bad shapes Service Business rules DTO allowlist Context checks Stops mass assign Repository Parameterized Query params Escape / prepare Stops injection Principles: Allowlist over blocklist  •  Fail closed on any failure  •  Validate before use, encode for output  •  Never trust the client
A request must survive every layer. A schema that passes the controller still faces DTO allowlisting and parameterized queries downstream.

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.

DimensionValidationSanitization
What it doesChecks input against rules, rejects if it failsModifies input to strip or encode dangerous content
OutcomeAccept or reject (fail closed)Always returns a value (cleaned)
Best forType, format, length, range, allowed valuesHTML/output encoding, stripping Mongo operators
Exampleemail must match RFC 5322 + maxLength: 255Remove keys starting with $ before a Mongo query
Risk if skippedMalformed/hostile data reaches logicValid-but-dangerous data reaches an output context
When to useAlways, first — at the API boundaryAfter 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}%`]
);
Advertisement

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

What is the difference between input validation and sanitization?

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.

Should I validate input on the client, server, or both?

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.

What is JSON Schema and how does it help API security?

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.

How do I prevent SQL injection in API endpoints?

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.

How do I prevent NoSQL injection in MongoDB APIs?

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.

What is mass assignment and how do I prevent it?

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.

How should I validate file uploads in APIs?

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.

What content types should my API accept and how do I enforce them?

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.

How do I handle validation errors in API responses?

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.

Should I use allowlists or blocklists for input validation?

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.

API securityinput validationinjection preventionJSON Schemasanitization
Advertisement