Input Validation Best Practices: Sanitization, Schema Validation, and Security

Share

TL;DR

The #1 input validation best practice is to never trust user input. Validate on both client (for UX) and server (for security). Use schema validation libraries like Zod, check data types, enforce length limits, and sanitize output. This prevents 79% of injection and data corruption vulnerabilities.

"All input is evil until proven otherwise. Validate everything, trust nothing."

The Cardinal Rule: Never Trust User Input

Every piece of data from users, URL parameters, cookies, and headers can be malicious:

Input sources to validate
// All of these need validation:
req.body          // Form data, JSON payloads
req.params        // URL parameters (/users/:id)
req.query         // Query strings (?page=1)
req.headers       // Headers (authorization, content-type)
req.cookies       // Cookies
formData.get()    // FormData from forms
event.target.value // User input in forms

Best Practice 1: Use Schema Validation 3 min

Schema validation ensures data matches expected structure and types:

Zod schema validation
import { z } from 'zod';

// Define the expected shape
const userSchema = z.object({
  email: z.string().email('Invalid email format'),
  name: z.string()
    .min(2, 'Name must be at least 2 characters')
    .max(100, 'Name must be under 100 characters')
    .regex(/^[a-zA-Z\s'-]+$/, 'Name contains invalid characters'),
  age: z.number()
    .int('Age must be a whole number')
    .min(13, 'Must be at least 13 years old')
    .max(120, 'Invalid age'),
  website: z.string().url().optional(),
});

// Validate input
function validateUser(input: unknown) {
  const result = userSchema.safeParse(input);

  if (!result.success) {
    return {
      valid: false,
      errors: result.error.issues.map(i => ({
        field: i.path.join('.'),
        message: i.message,
      })),
    };
  }

  return { valid: true, data: result.data };
}

// Usage in API route
app.post('/api/users', (req, res) => {
  const validation = validateUser(req.body);

  if (!validation.valid) {
    return res.status(400).json({ errors: validation.errors });
  }

  // validation.data is now typed and safe to use
  createUser(validation.data);
});

Best Practice 2: Validate on Client AND Server 2 min

Both are necessary for different reasons:

Validation LocationPurposeCan Be Bypassed?
Client-sideUser experience (instant feedback)Yes (easily)
Server-sideSecurity (actual protection)No

Never rely on client-side validation alone. Attackers can easily bypass JavaScript validation by sending requests directly to your API. Server-side validation is required for security.

Best Practice 3: Validate Specific Types 3 min

Email Validation

Email validation
const emailSchema = z.string()
  .email('Invalid email format')
  .max(254, 'Email too long')
  .toLowerCase()
  .trim();

// Or with custom regex for stricter validation
const strictEmailSchema = z.string()
  .regex(
    /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/,
    'Invalid email format'
  );

URL Validation

URL validation (preventing javascript: URLs)
const urlSchema = z.string()
  .url('Invalid URL format')
  .refine(
    (url) => {
      try {
        const parsed = new URL(url);
        return ['http:', 'https:'].includes(parsed.protocol);
      } catch {
        return false;
      }
    },
    'Only HTTP and HTTPS URLs allowed'
  );

ID Validation

ID validation
// UUID validation
const uuidSchema = z.string().uuid('Invalid ID format');

// Numeric ID (for SQL databases)
const numericIdSchema = z.coerce.number()
  .int('ID must be an integer')
  .positive('ID must be positive');

// Slug validation
const slugSchema = z.string()
  .regex(/^[a-z0-9-]+$/, 'Invalid slug format')
  .min(1)
  .max(100);

Best Practice 4: Sanitize for Output Context 2 min

Different contexts require different sanitization:

Context-specific sanitization
import DOMPurify from 'dompurify';

// HTML context: escape or sanitize
function sanitizeForHtml(input: string) {
  // Option 1: Escape (safest, no HTML allowed)
  return input
    .replace(/&/g, '&')
    .replace(//g, '>')
    .replace(/"/g, '"')
    .replace(/'/g, ''');

  // Option 2: Sanitize (allow some HTML)
  return DOMPurify.sanitize(input, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a'],
    ALLOWED_ATTR: ['href'],
  });
}

// SQL context: use parameterized queries (not string escaping)
// See database best practices

// URL context: encode
function sanitizeForUrl(input: string) {
  return encodeURIComponent(input);
}

// JSON context: stringify handles escaping
JSON.stringify({ userInput: input });

Best Practice 5: Enforce Length Limits 1 min

Prevent denial of service and database issues:

Length limit validation
const commentSchema = z.object({
  content: z.string()
    .min(1, 'Comment cannot be empty')
    .max(10000, 'Comment too long (max 10,000 characters)'),
});

const fileUploadSchema = z.object({
  name: z.string().max(255, 'Filename too long'),
  size: z.number().max(10 * 1024 * 1024, 'File too large (max 10MB)'),
});

// Also limit array lengths
const tagsSchema = z.array(z.string().max(50))
  .max(20, 'Too many tags (max 20)');

Best Practice 6: Validate File Uploads 2 min

Files need special validation:

File upload validation
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB

function validateFile(file: File) {
  const errors = [];

  // Check MIME type
  if (!ALLOWED_TYPES.includes(file.type)) {
    errors.push('Invalid file type. Allowed: JPEG, PNG, WebP');
  }

  // Check file size
  if (file.size > MAX_SIZE) {
    errors.push('File too large. Maximum size: 5MB');
  }

  // Check file extension matches MIME type
  const ext = file.name.split('.').pop()?.toLowerCase();
  const validExtensions = ['jpg', 'jpeg', 'png', 'webp'];
  if (!ext || !validExtensions.includes(ext)) {
    errors.push('Invalid file extension');
  }

  return {
    valid: errors.length === 0,
    errors,
  };
}

Common Input Validation Mistakes

MistakeRiskPrevention
Client-only validationComplete bypassAlways validate server-side
Type coercion issuesUnexpected behaviorExplicit type checking
Missing length limitsDoS, buffer issuesSet reasonable maximums
Trusting file extensionsMalicious uploadsCheck MIME type too
Accepting javascript: URLsXSS attacksWhitelist allowed protocols

External Resources: For comprehensive input validation guidelines, see the OWASP Input Validation Cheat Sheet and the OWASP Validation Regex Repository.

Which validation library should I use?

Zod is the most popular choice for TypeScript projects due to its excellent type inference. Yup is also good, especially with Formik. For simple cases, you can use built-in methods, but libraries provide better error messages and composability.

Should I sanitize input or output?

Validate and sanitize input, but also sanitize output based on context. Input validation catches obvious issues early. Output encoding/sanitization prevents injection in the specific context (HTML, SQL, URL) where the data is used.

How strict should validation be?

Be as strict as your use case allows without hurting legitimate users. For security-critical fields (passwords, IDs), be very strict. For user content, balance security with usability. Allow international characters in names, for example.

Is TypeScript enough for validation?

No. TypeScript types exist only at compile time. Runtime data (API requests, form submissions) is not type-checked by TypeScript. You need runtime validation like Zod to ensure data matches expected types.

Verify Your Input Validation

Scan your application for input validation vulnerabilities.

Start Free Scan
Best Practices

Input Validation Best Practices: Sanitization, Schema Validation, and Security