Skip to main content
Security7 min readMarch 3, 2026

Input Validation: The First Line of Defense Against Every Attack

Build a systematic input validation strategy — schema validation with Zod, type coercion, allowlists vs. blocklists, file upload validation, and validation at every layer.

James Ross Jr.

James Ross Jr.

Strategic Systems Architect & Enterprise Software Developer

Input Validation: The First Line of Defense Against Every Attack

Every attack on a web application uses user-controlled input as the entry point. SQL injection sends SQL syntax as user input. XSS sends HTML and JavaScript as user input. Buffer overflows send more data than expected. Server-side request forgery sends a malicious URL as user input. Path traversal sends ../../../etc/passwd as a filename.

If your application correctly validates all user input before processing it, these attacks fail at the first step. The malicious SQL never reaches the query. The malicious script never reaches the HTML. The oversized payload never reaches the parser.

Input validation is not sufficient on its own — you still need parameterized queries, output encoding, and other defenses — but it is the first line that stops the most attacks before they can even attempt exploitation.

The Validation Mindset: Allowlists, Not Blocklists

The fundamental question in input validation is: what do I want to allow? Not: what do I want to reject?

A blocklist approach says "reject input containing <script>, --, DROP TABLE, or other known-bad patterns." This approach fails because attackers know what you are blocking and find evasions. <sCrIpT> bypasses a case-sensitive script filter. DROP/*comment*/TABLE bypasses a simple pattern match. The blocklist is never complete.

An allowlist approach says "accept only input that matches my expected format." A username field that allows only [a-zA-Z0-9_-] and enforces a length of 3-50 characters cannot contain HTML, SQL, or shell metacharacters — not because you blocked them, but because they do not match the allowed pattern. No attacker can sneak a malicious character past an allowlist that does not include it.

Define your allowlist positively: what types and formats are valid? Anything outside that set is rejected, regardless of what specific malicious pattern it contains.

Schema Validation with Zod

Zod is the right tool for declarative schema validation in TypeScript. Define your expected input shape, and Zod validates that actual input conforms to it.

import { z } from "zod";

// Define what valid input looks like
const CreateUserSchema = z.object({
  username: z
    .string()
    .min(3, "Username must be at least 3 characters")
    .max(50, "Username must be at most 50 characters")
    .regex(/^[a-zA-Z0-9_-]+$/, "Username may only contain letters, numbers, underscores, and hyphens"),

  email: z
    .string()
    .email("Must be a valid email address")
    .max(255, "Email must be at most 255 characters")
    .toLowerCase(), // Normalize to lowercase

  age: z
    .number()
    .int("Age must be a whole number")
    .min(13, "Must be at least 13 years old")
    .max(120, "Age must be realistic"),

  bio: z
    .string()
    .max(500, "Bio must be at most 500 characters")
    .optional(),

  role: z
    .enum(["user", "editor"]) // Only allow specific values
    .default("user"),
});

type CreateUserInput = z.infer<typeof CreateUserSchema>;

// In your handler
app.post("/api/users", async (req, res) => {
  const result = CreateUserSchema.safeParse(req.body);

  if (!result.success) {
    return res.status(400).json({
      error: "Validation failed",
      details: result.error.flatten().fieldErrors,
    });
  }

  // result.data is typed and validated
  const user = await createUser(result.data);
  res.status(201).json(user);
});

safeParse returns a discriminated union — either { success: true, data: ValidatedType } or { success: false, error: ZodError }. No exceptions to catch, no runtime surprises. The validated result.data is typed — TypeScript knows every field has passed validation.

Type Coercion vs. Type Assertion

HTTP query parameters and form bodies are always strings. A request to /api/orders?limit=10&page=2 provides limit and page as strings "10" and "2", not numbers. Type coercion converts them to the right type before validation.

Zod provides z.coerce for this:

const QuerySchema = z.object({
  limit: z.coerce.number().int().min(1).max(100).default(20),
  page: z.coerce.number().int().min(1).default(1),
  sort: z.enum(["asc", "desc"]).default("desc"),
  q: z.string().max(100).optional(),
});

app.get("/api/orders", async (req, res) => {
  const query = QuerySchema.parse(req.query); // Coerces and validates
  const orders = await getOrders(query);
  res.json(orders);
});

z.coerce.number() converts the string "10" to the number 10 before validation. If the string cannot be coerced to a number ("abc"), validation fails with a clear error.

Without coercion, you end up with type assertions (Number(req.query.limit)) that produce NaN for invalid input rather than a validation error.

Validating Nested and Complex Data

API requests often contain nested objects and arrays. Validate them recursively with Zod:

const CreateOrderSchema = z.object({
  items: z
    .array(
      z.object({
        productId: z.string().uuid("Product ID must be a valid UUID"),
        quantity: z.coerce.number().int().min(1).max(100),
        customization: z
          .object({
            color: z.string().max(50).optional(),
            size: z.enum(["xs", "s", "m", "l", "xl", "xxl"]).optional(),
          })
          .optional(),
      })
    )
    .min(1, "Order must contain at least one item")
    .max(50, "Order cannot contain more than 50 items"),

  shippingAddress: z.object({
    street: z.string().min(1).max(200),
    city: z.string().min(1).max(100),
    state: z.string().length(2), // Two-letter state code
    postalCode: z.string().regex(/^\d{5}(-\d{4})?$/, "Invalid US postal code"),
    country: z.literal("US"), // Only US for now
  }),

  promoCode: z.string().max(20).optional(),
});

This schema validates the entire request structure in one pass. An array of items with each item validated, an address with format requirements on the postal code, a strictly allowlisted set of size values. No malformed data reaches your business logic.

File Upload Validation

File uploads are a particularly sensitive validation surface. Files can be large (denial of service), can have misleading extensions, can contain malicious content, and can be served from your server if you are not careful.

import multer from "multer";
import { createReadStream } from "fs";

const ALLOWED_MIME_TYPES = new Set([
  "image/jpeg",
  "image/png",
  "image/webp",
  "image/gif",
]);

const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB

const upload = multer({
  limits: { fileSize: MAX_FILE_SIZE },
  fileFilter: (req, file, cb) => {
    // Check Content-Type header
    if (!ALLOWED_MIME_TYPES.has(file.mimetype)) {
      cb(new Error(`File type ${file.mimetype} not allowed`));
      return;
    }
    cb(null, true);
  },
  storage: multer.memoryStorage(), // Process in memory, then validate content
});

Content-Type validation from the request header is not sufficient — clients can send any MIME type regardless of the actual file content. Also validate the file's magic bytes:

import FileType from "file-type";

async function validateFileContent(buffer: Buffer): Promise<boolean> {
  const fileType = await FileType.fromBuffer(buffer);

  if (!fileType) return false; // Could not determine type from content

  return ALLOWED_MIME_TYPES.has(fileType.mime);
}

file-type reads the file's magic bytes (the first few bytes that identify the file format) and determines the actual type regardless of what the client claimed. An attacker who renames malicious.php to avatar.jpg will fail this check because the file's magic bytes identify it as PHP, not JPEG.

URL and Redirect Validation

Redirects to user-supplied URLs are a common source of open redirect vulnerabilities (attackers use your trusted domain for phishing) and SSRF vulnerabilities (attackers make your server request internal resources).

function validateRedirectUrl(url: string, baseUrl: string): string {
  // Reject URLs that look like javascript:
  if (url.toLowerCase().startsWith("javascript:")) {
    return "/";
  }

  try {
    const parsed = new URL(url, baseUrl);

    // Only allow same-origin redirects
    const base = new URL(baseUrl);
    if (parsed.origin !== base.origin) {
      return "/"; // Return to homepage for external redirects
    }

    return parsed.pathname + parsed.search + parsed.hash;
  } catch {
    // URL parsing failed — invalid URL
    return "/";
  }
}

For internal URLs, validate against your own origin. For cases where you legitimately need to redirect to external URLs, use an explicit allowlist of permitted external domains.

Validation at Every Layer

A common mistake is validating only at the API boundary and trusting validated data through the rest of the system. Defense in depth means validating at multiple layers:

At the API boundary — validate the HTTP request structure with Zod before any processing.

At the service layer — validate business rules: does this product ID exist? Is this quantity available in inventory? Does this user have permission to perform this action?

At the database layer — database constraints (NOT NULL, CHECK, UNIQUE, FOREIGN KEY) enforce invariants at the storage level. Even if a bug in your application bypasses service-level validation, the database rejects invalid data.

-- Database-level validation
CREATE TABLE orders (
  id UUID PRIMARY KEY,
  user_id UUID NOT NULL REFERENCES users(id),
  quantity INTEGER NOT NULL CHECK (quantity BETWEEN 1 AND 100),
  status VARCHAR(20) NOT NULL CHECK (status IN ('pending', 'processing', 'shipped', 'delivered', 'cancelled')),
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Database constraints are enforced by the database engine, not your application code. A bug that bypasses your application validation still hits the database constraint. This is the final backstop.

Making Validation Errors Useful

Validation errors should tell the user exactly what is wrong. Generic "validation failed" messages are unhelpful and lead to support tickets.

// Unhelpful
return res.status(400).json({ error: "Invalid request" });

// Helpful
return res.status(400).json({
  error: "Validation failed",
  details: {
    username: ["Username may only contain letters, numbers, underscores, and hyphens"],
    age: ["Must be at least 13 years old"],
  },
});

Zod's flatten() method produces field-level error messages that map directly to form fields. Your frontend can display errors inline next to the relevant field, improving the user experience while providing actionable feedback.


If you want help building a systematic input validation strategy for your application or want a review of your current validation coverage, book a session at https://calendly.com/jamesrossjr.


Keep Reading