codingstairs
NotesEDULifeContact
⌕Search⌘K
koen

Navigation

  • Intro
  • Blog
  • Life

Get in touch

Send without signing in. Add your email if you'd like a reply.

  • Leave a message anonymously →
  • ✉ warragon112@gmail.com
  • KakaoTalk Open Chat ↗

© 2026 codingstairs

  • Notes
  • EDU
  • Search
  • Life
  • Contact
  • Legal
  • RSS
  • GitHub
Notes›security

Input Validation — Trim at the Boundary

Published 2026-04-28· Updated 2026-05-18·0 views

Input Validation — Trim at the Boundary

Untrusted input arrives at the edges of the system. HTTP body, query, headers, environment variables, external API responses, file contents. Letting that input flow into the inner code blurs both type and meaning, and security incidents often start exactly at the edges. This article covers boundary validation, zod and its siblings, and how to split validation between client and server.

1. Boundary validation

The core principle — untrusted input is validated once at the system boundary and converted into a trusted type. The inner code then assumes that trusted type.

The boundaries:

  • HTTP handlers — body, query, path, headers.
  • Environment variables — once at startup.
  • Right after JSON parsing — unknown → typed.
  • External API responses — the response schema can change.
  • Message queue consumers — old message shapes are possible.

The result of validation is a value with guaranteed type and meaning; the result of failure is a clear error response.

2. zod

A TypeScript validation library started in 2020 by Colin McDonnell. The core idea: "the schema definition is the type."

import { z } from 'zod';

const UserSchema = z.object({
  id: z.number().int().positive(),
  email: z.string().email(),
  age: z.number().int().min(0).max(150).optional(),
});

type User = z.infer<typeof UserSchema>;

const result = UserSchema.safeParse(input);
if (!result.success) {
  // structured messages in result.error
} else {
  const user = result.data; // type: User
}
  • Composable schemas (z.union, z.intersection, z.discriminatedUnion).
  • Transformations (z.transform) — validate plus normalize.
  • Async validation (z.refine async).

3. Other tools in the TypeScript world

Library Origin Model Notes
zod 2020 Method chaining Strong type inference; most widely used
valibot 2023 Functional pipe Small bundle
ArkType 2023 Type expressions Strong inference; learning curve
yup 2014 Method chaining Older standard
joi 2012 Builder Node-friendly; weak inference
superstruct 2018 Functional Lightweight

valibot is reported to tree-shake better thanks to functional composition (pipe(string(), email())). ArkType expresses schemas with the type system itself — strong inference but a steeper learning curve.

4. Other languages

Python:

  • Pydantic (2017, Samuel Colvin) — class-based data validation. v2 (2023) rewrote the core in Rust for performance. Foundation of FastAPI.
  • marshmallow (2014) — schema and serialization. The standard of the Flask era.
  • attrs / dataclasses + validation — standard-library oriented.

JVM:

  • Bean Validation (JSR 380) — annotations like @NotNull, @Size, @Email.
  • Spring @Valid + Validated — Bean Validation integration.

Rust — serde + validator (separates serialization and validation).

The pattern is the same regardless of language. Untrusted shape → validation → typed value.

5. Per-location application

HTTP body:

// Next.js Route Handler
export async function POST(req: Request) {
  const json = await req.json();
  const parsed = UserSchema.safeParse(json);
  if (!parsed.success) return Response.json({ errors: parsed.error.issues }, { status: 400 });
  // parsed.data is validated
}

Query / Path — query parameters are all strings. If you need numbers or booleans, convert explicitly:

const QuerySchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
});

Headers — Authorization, X-Api-Key, and similar. Specify format and length.

Environment variables — validate once at startup so misconfiguration fails fast:

const Env = z.object({
  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  NODE_ENV: z.enum(['development', 'staging', 'production']),
});

export const env = Env.parse(process.env);

It must pass at build time or startup for the process to boot.

Right after JSON parsing — JSON.parse(...) returns any. Do not cast (as User); validate.

External API responses — if the API changes silently, the old code breaks. Validating the response by schema catches the change at ingestion.

6. Client / server split

Principles:

  • Server validation always — the deciding factor for security.
  • Client validation is for UX — quick feedback, fewer server calls.
  • Shared schema — both client and server commonly import the same zod schema.
// shared/user-schema.ts
export const UserSchema = z.object({...});

This pattern is most natural in a monorepo or single codebase.

Form libraries:

  • React Hook Form + @hookform/resolvers/zod — useForm({ resolver: zodResolver(schema) }).
  • TanStack Form — its own validation plus a zod adapter.
  • Conform — server-action friendly forms.

Server actions (Next.js) need the same validation. They can be invoked from non-client environments, so form-library validation alone is insufficient.

7. Error response shape

Validation failures should follow a consistent shape. Refer to a standard like RFC 9457 (Problem Details for HTTP APIs, 2023):

{
  "type": "about:blank",
  "title": "Validation Error",
  "status": 400,
  "errors": [
    { "path": "email", "message": "Invalid email" }
  ]
}

8. Safe defaults and evolution

Partial updates — for PATCH, use .partial() or .deepPartial() to make every field optional:

const UserUpdate = UserSchema.partial();

Strict mode — z.object(...) strips unknown keys by default. To reject unknown keys use .strict(). To preserve them use .passthrough().

Schema evolution — when older shapes still exist in storage, lift them with a union or transform:

const Legacy = z.object({ name: z.string() });
const Current = z.object({ firstName: z.string(), lastName: z.string() });
const Either = z.union([Current, Legacy.transform(({ name }) => ({ firstName: name, lastName: '' }))]);

9. Common pitfalls

Bypassing validation with as — TypeScript's as User means nothing at runtime. Trust the type only after validation.

Partial-validation trap — validating one object but leaving nested objects alone scatters trust by depth. Use a deep schema.

Mixing transform and validation — transform produces a normalized output whose type differs from input. Separate input and output schemas for the API.

Error-message exposure — internal details (table names, SQL) should not leak to users.

Too-lenient schemas — z.unknown() everywhere defeats validation.

Ignoring external API drift — when a response changes, ingestion fails, but that failure must not leak into the user response. Separate ingestion and presentation.

Missing i18n messages — zod's default English messages going straight to users feels off. Map the messages.

Bundle size — a heavy validation library on the client affects initial load. Tree-shake or split runtimes.

Closing thoughts

Validate untrusted input once at the system boundary so the inner code can assume a trusted type — that simple principle improves security, type safety, and debugging together. Client validation is UX; server validation is security. A shared schema keeps the two definitions from drifting.

Next

  • password-hashing
  • headers-and-cors

We refer to zod official, valibot official, ArkType official, Pydantic official, Bean Validation spec, RFC 9457 Problem Details, and the OWASP Input Validation Cheat Sheet.

More in security

All in this category →
  • Public-route allow-list — keep it in sync when adding domains
  • Anonymous forms — minimum safety net
  • Security Headers and CORS
  • Password Hashing — bcrypt, scrypt, Argon2
  • Rate Limiting — Algorithms and Implementation
  • OAuth — state, PKCE, OIDC