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

TL;DR

Never trust user input. That's the whole rule. Validate on the client for UX and on the server for real security, use a schema library like Zod, check types, cap lengths, and sanitize output before it's used. Get this right and you close off 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

Users, URL parameters, cookies, headers. Any of it can be malicious, and none of it gets a pass just because it looks fine on the surface:

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 checks that data matches the shape and type you expect before your code ever touches it:

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

You need both, and 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 don't fill out your form. They send requests straight to your API and skip the JavaScript entirely, so server-side validation is what actually protects you.

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

The sanitization you need depends entirely on where the data ends up:

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

Skip this and you're one oversized payload away from a denial of service, or a database column that chokes on what it wasn't built to hold:

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 are their own animal. A few extra checks beyond the usual schema:

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.

Further Reading

Put these practices into action with our step-by-step guides.

Verify Your Input Validation

Scan your application for input validation vulnerabilities.

Best Practices

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