AlgoInsight

Free lesson · Node.js Deep Dive

Input Validation: Zod, Joi, and the Principle

Medium10 min readnodejsjavascriptbackend

Lesson 16.1 - Input Validation: Zod, Joi, and the Principle

This is the foundation security lesson. Most security vulnerabilities - injection, XSS, broken access control, server-side request forgery - share a common root: untrusted input was treated as trusted somewhere in the code path. Input validation is the practice of not doing that. Done properly at every trust boundary, it eliminates entire classes of vulnerabilities.

The honest framing: most engineers know they should validate input. The lesson teaches how to do it systematically - what counts as input, where the trust boundaries are, how to validate at scale without 400 lines of if statements, and the difference between validation and sanitization. By the end you should be using schema-based validation reflexively, the way you reflexively write tests.

I came to this discipline through working on VS Code Remote at Microsoft. The product spawned processes on user-controlled remote machines - every input from those machines was potentially hostile. Schema validation everywhere was a hard requirement. The patterns I learned there carry over to any service that handles external requests.


The Trust-No-Input Stance

Every input crossing into your code is potentially hostile. Not just because attackers exist, but because:

The mindset is structural: assume every external value is wrong until proven otherwise. Not "validate the user input box"; validate every byte that crosses a trust boundary.

What counts as input?

flowchart TB
    subgraph "Trusted (your code)"
        Code["Application Code"]
    end

    subgraph "Trust Boundaries"
        HTTP["HTTP body, query, headers, params"]
        File["File uploads"]
        DB["Database results"]
        ENV["Environment variables"]
        FS["File system reads"]
        IPC["IPC messages from workers"]
        Net["Outbound API responses"]
    end

    subgraph "Untrusted Sources"
        User["User browsers"]
        Other["Other services"]
        Internet["External APIs"]
        Disk["Disk"]
        Config["Config files"]
    end

    User --> HTTP
    User --> File
    Other --> HTTP
    Other --> IPC
    Internet --> Net
    Disk --> FS
    Disk --> DB
    Config --> ENV

    HTTP --> Validate1[Validate]
    File --> Validate2[Validate]
    DB --> Validate3[Validate]
    ENV --> Validate4[Validate]
    FS --> Validate5[Validate]
    IPC --> Validate6[Validate]
    Net --> Validate7[Validate]

    Validate1 & Validate2 & Validate3 & Validate4 & Validate5 & Validate6 & Validate7 --> Code

    style HTTP fill:#fbbf24,stroke:#f59e0b,color:#000
    style File fill:#fbbf24,stroke:#f59e0b,color:#000
    style DB fill:#fbbf24,stroke:#f59e0b,color:#000
    style ENV fill:#fbbf24,stroke:#f59e0b,color:#000
    style FS fill:#fbbf24,stroke:#f59e0b,color:#000
    style IPC fill:#fbbf24,stroke:#f59e0b,color:#000
    style Net fill:#fbbf24,stroke:#f59e0b,color:#000
    style Code fill:#34d399,stroke:#10b981,color:#000
A diagram of trust boundaries in a typical service. Untrusted sources include user browsers, other services, external APIs, disk storage, and configuration files. Each crosses into your application through specific boundaries: HTTP requests with their bodies, query parameters, headers, and path parameters; file uploads; database query results; environment variables; file system reads; inter-process communication from worker threads; and outbound API responses. At each boundary, the input must be validated before being treated as trustworthy. Inside the validated zone is your application code, which can safely operate on the parsed and verified data.

The boundaries most commonly missed:

Database results. "It's in our database, it must be valid." Not necessarily - data was written when validation rules were different; or a different service wrote it without validation; or a corruption happened. Treat reads from the database as untrusted if the values are crossing security-sensitive paths.

Environment variables. They look like configuration. They're often controlled by whoever deploys, which might include third parties (CI/CD, container orchestration). Validate at startup.

IPC and worker messages. Worker threads, child processes, message queues. The other side might be your code, but the messages still cross a serialization boundary. Validate.

Outbound API responses. You called a trusted API; it returned malformed data because the third-party service had a bug. Your code crashes processing the unexpected shape. Validation catches it.

The principle: any value crossing into your trust zone gets validated, regardless of source. No exceptions for "trusted" sources.


Why TypeScript Isn't Enough

A natural objection: "I use TypeScript; my types are checked." TypeScript types are erased at compile time. At runtime, a value annotated User could be anything - an empty object, null, a malicious payload, anything.

interface User {
  id: string;
  email: string;
  role: 'admin' | 'user';
}

app.post('/api/users', (req, res) => {
  const user: User = req.body;   // LIE
  // user could be { role: 'admin', __proto__: { ... } } or anything else
});

The annotation : User is decorative at runtime. TypeScript trusts you to put the right thing there; runtime doesn't enforce it.

Schema validation libraries close this gap. They parse the input, verify it matches a schema, and produce a typed value that's actually safe to use:

import { z } from 'zod';

const UserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  role: z.enum(['admin', 'user']),
});

type User = z.infer<typeof UserSchema>;

app.post('/api/users', (req, res) => {
  const result = UserSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({ error: result.error.flatten() });
  }
  const user: User = result.data;   // ACTUALLY safe
});

Now the type assertion is backed by runtime verification. If the body doesn't match the schema, the parse fails; the 400 response goes out; the handler never sees the malformed data.

This pattern - define schema, parse input, work with parsed result - is the core of modern input validation. The schema does double duty: runtime validation and TypeScript type generation. One source of truth.


Zod: The Modern Default

Zod is the schema validation library that has become the de facto standard for new TypeScript projects. It's TypeScript-first, has excellent type inference, and the API is pleasant.

import { z } from 'zod';

// Simple values
const nameSchema = z.string().min(2).max(100);
const ageSchema = z.number().int().nonnegative();
const emailSchema = z.string().email();

// Objects
const UserCreateSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8).max(72),
  name: z.string().min(2).max(100),
  age: z.number().int().nonnegative().optional(),
  preferences: z.object({
    notifications: z.boolean(),
    theme: z.enum(['light', 'dark', 'auto']),
  }).optional(),
});

// Arrays
const TagsSchema = z.array(z.string()).max(20);

// Unions
const ResponseSchema = z.union([
  z.object({ status: z.literal('ok'), data: z.any() }),
  z.object({ status: z.literal('error'), error: z.string() }),
]);

// Refinements (custom rules)
const PasswordSchema = z.string()
  .min(8)
  .refine(s => /[A-Z]/.test(s), 'Must contain uppercase')
  .refine(s => /[0-9]/.test(s), 'Must contain digit');

Usage with parse (throws on failure) or safeParse (returns a result):

// Throws ZodError on failure
const user = UserCreateSchema.parse(req.body);

// Returns { success: true, data } or { success: false, error }
const result = UserCreateSchema.safeParse(req.body);
if (!result.success) {
  return res.status(400).json({ errors: result.error.flatten() });
}
const user = result.data;

For Express, safeParse is the right choice - you want to return a structured error response, not propagate a ZodError up to error middleware.

Express middleware that validates and attaches the parsed result:

function validate<T extends z.ZodTypeAny>(schema: T) {
  return (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      return res.status(400).json({
        error: 'Validation failed',
        details: result.error.flatten(),
      });
    }
    req.body = result.data;   // replace with parsed (typed) version
    next();
  };
}

app.post('/api/users', validate(UserCreateSchema), (req, res) => {
  // req.body is typed as z.infer<typeof UserCreateSchema>
});

This middleware pattern means every endpoint gets validation declared at the route level, type-safely. The handler can trust req.body because the middleware has parsed it.

For other parts of the request - query, params, headers - apply the same pattern. Each gets a schema; each is parsed before the handler runs.


Joi: The Older Alternative

Joi predates Zod by years; it's still widely used.

const Joi = require('joi');

const userSchema = Joi.object({
  email: Joi.string().email().required(),
  password: Joi.string().min(8).max(72).required(),
  name: Joi.string().min(2).max(100).required(),
  age: Joi.number().integer().min(0).optional(),
});

const { error, value } = userSchema.validate(req.body);
if (error) {
  return res.status(400).json({ error: error.details });
}
const user = value;

Joi is feature-complete and well-maintained. The reasons to pick it:

The reasons to prefer Zod:

For new TypeScript projects, Zod is the default. For Joi codebases, stay with Joi unless there's a specific reason to migrate.


Other Libraries Worth Knowing

Valibot - newer, smaller bundle size. Useful for client-side validation where bundle size matters; server-side it's a Zod alternative without strong differentiation.

Yup - older alternative; mostly seen in React form libraries.

ArkType - TypeScript-syntax-based schemas. Interesting; not yet dominant.

class-validator + class-transformer - decorator-based; common in NestJS. Works well in that ecosystem; less ergonomic outside.

TypeBox - JSON Schema-based; useful if you also want to publish OpenAPI/JSON Schema documents.

For most Node services, Zod or Joi covers what you need. Don't introduce a second validation library; consistency matters.


Validation Patterns

A few principles that emerge across schemas:

Validate at the Boundary

Validation belongs at the trust boundary - typically in middleware, before the handler. Deep validation in business logic creates two problems:

  1. The handler signature lies - its parameters are typed but might not be valid.
  2. Validation duplicates across every code path that calls the function.

Better: validate once at the entry; the rest of the code trusts the result.

Parse, Don't Validate

A subtle distinction. "Validate" suggests checking and returning yes/no; "parse" suggests transforming an untyped input into a typed output. The latter is what you want.

// "Validate" (anti-pattern)
function isValidUser(input: unknown): boolean { ... }

if (isValidUser(req.body)) {
  const user = req.body as User;   // still a lie at runtime
  // ...
}

// "Parse" (good)
function parseUser(input: unknown): User { 
  return UserSchema.parse(input);
}

const user = parseUser(req.body);   // user is actually safe

The parse pattern flows the type information through. The validate pattern requires a cast, which is a lie.

Whitelist Over Blacklist

Validate by specifying what is allowed (whitelist), not what is forbidden (blacklist). Blacklists are inherently incomplete - there's always a forbidden thing you didn't think of.

// BAD - blacklist
const dangerous = ['<script>', 'javascript:', 'onerror='];
if (dangerous.some(d => input.includes(d))) reject();
// (missed: <img onerror=...>, vbscript:, data:text/html, etc.)

// GOOD - whitelist  
const safeURLSchema = z.string().regex(/^https:\/\/[\w.-]+\.example\.com\/.*$/);

For URLs specifically, parse with the URL constructor and check the parsed result:

function isSafeURL(input: string): boolean {
  try {
    const url = new URL(input);
    return url.protocol === 'https:' && url.hostname.endsWith('.example.com');
  } catch {
    return false;
  }
}

The parser handles all the URL syntax edge cases for you; you check the parsed structure.

Fail Closed

When validation fails, the operation should fail - not proceed with partial data, default values that change behavior, or a "best effort" interpretation.

// BAD - fails open
const limit = parseInt(req.query.limit) || 100;
// (if limit is invalid, silently use 100; user thinks they're querying with their limit)

// GOOD - fails closed
const QuerySchema = z.object({
  limit: z.coerce.number().int().min(1).max(1000).default(100),
});
const { limit } = QuerySchema.parse(req.query);   // throws on bad input

The "default" in Zod runs only when the value is undefined, not when it's invalid. Invalid values still fail.

Coercion Is Sometimes Needed

HTTP query strings are always strings. ?limit=10 arrives as '10', not 10. Coerce explicitly:

const QuerySchema = z.object({
  limit: z.coerce.number().int().positive(),
  active: z.coerce.boolean().optional(),
});

z.coerce.number() calls Number() on the input before validation. '10' becomes 10; 'abc' becomes NaN and fails. Coercion is fine here; the input is known to be a string and the conversion is well-defined.


Validating Database Results

Most code doesn't validate database results - and most of the time it's fine. The values came from your own code; they should be the shape you wrote.

But: schema migrations leave old data with old shapes. Different services write to the same table. Data corruption happens. For security-sensitive paths, validate database results too.

const dbUser = await db.query('SELECT * FROM users WHERE id = 
', [id]); const user = UserSchema.parse(dbUser); // verify structure

If the database returns something unexpected, you'd rather crash with a structured error than continue with corrupt data. Schema validation gives you that guarantee.

For high-volume paths where the validation cost is significant, you can be selective - validate user-facing data; skip validation for internal aggregation queries. The principle of cost-vs-risk applies.


Output Encoding (Briefly)

Input validation prevents bad data from entering. Output encoding prevents bad data from being interpreted as code on the way out.

For HTML output, escape user-controlled values:

For SQL, use parameterized queries (never string concatenation).

For shell commands, use the array form of spawn (never exec with concatenated strings).

For JSON, the standard JSON.stringify produces safe output.

The general rule: encode at the point of output, for the specific output context. HTML encoding for HTML; SQL parameterization for SQL; etc. A single "sanitize once" function trying to handle all contexts is fragile.


What I Want You to Take From This Lesson

Six things.

One: Every input crossing into your trust zone gets validated. No exceptions.

Two: Schema validation is the modern pattern. Zod for TypeScript; Joi for legacy.

Three: Parse, don't validate. Produce typed output from untyped input.

Four: Validate at the boundary, not deep in business logic. Middleware is the right place.

Five: Whitelist what's allowed; don't blacklist what's forbidden. Whitelists are inherently complete.

Six: Output encoding is separate from input validation. Both are necessary.


Try It

Exercise: Warmup (20 minutes). Define Zod schemas for the inputs to an existing Express endpoint. Add a validation middleware. Test that malformed requests get 400; valid ones proceed.

Exercise: Core (30 minutes). For an API you control, audit every endpoint. Identify each input (body, query, params, headers). Write schemas for each. Note any cases where input was previously trusted that shouldn't have been.

Exercise: Core (25 minutes). Implement a validation pattern for environment variables: a Zod schema for the expected config; parse at startup; fail loudly if anything's missing or malformed. Verify the service refuses to start with bad config.

Exercise: Stretch (60 minutes). Take a real database query in your code. Add schema validation to the result. Note the cases where the database might return something unexpected (NULLs in non-nullable columns, type mismatches after migrations, etc.). Decide whether to validate by row or by query type.


What's Next

Lesson 16.2 covers authentication - the highest-stakes engineering decision in any service, and the one most often gotten wrong.

Back to Node.js Deep Dive