Engineering Notes
Home/Backend/API Design/Request Validation
Backend · API Design

Request Validation

Validate once at the boundary, then trust typed data everywhere else. Compare malformed (400) and semantically invalid (422) requests, reject unknown fields, and separate validation from sanitization.

⚙️ Backend● Beginner⏱ 5 min read▶ 2 interactive🛠️ 3 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • Validate at the boundary, trust everywhere else
  • Schema definition + parse instead of scattered ifs
  • 400 vs 422
  • Sanitization vs validation; strict mode

📚 Before you start

1Validate at the Boundary, Trust Everywhere Else

Every request entering an API crosses a trust boundary: everything before it is attacker-controlled input, everything after it should be able to assume clean, well-typed data. Validating at the boundary — before the request reaches business logic — means the rest of the codebase never has to defensively re-check "is this actually a number" ten call-frames deep; it can simply trust its own types, because the boundary already enforced them.

ts
// WITHOUT boundary validation: business logic has to defend against garbage everywhere
function createOrder(body: any) {
  if (typeof body.customerId !== "number") throw new Error("bad customerId"); // defensive check, buried deep
  // ... twenty lines later, another defensive check for another field ...
}

// WITH boundary validation: business logic trusts its input completely
function createOrder(input: CreateOrderInput) { // input is ALREADY validated by the time it gets here
  // no defensive checks needed -- the type is a genuine guarantee, not a hope
}

2Schema Definition + Parse, Not Scattered if Checks

Real validation libraries (Zod, Joi, Yup, class-validator) share a common pattern: declare the expected shape once as a schema, then call one .parse()/.validate() against it, rather than hand-writing a chain of if statements per field:

ts
import { z } from "zod";

const CreateOrderInput = z.object({
  customerId: z.number().int().positive(),
  items: z.array(z.object({
    productId: z.number().int().positive(),
    quantity: z.number().int().min(1).max(100),
  })).min(1),
  couponCode: z.string().optional(),
});

type CreateOrderInput = z.infer<typeof CreateOrderInput>; // the TS TYPE is derived FROM the schema -- one source of truth
ts
app.post("/orders", (req, res) => {
  const result = CreateOrderInput.safeParse(req.body);
  if (!result.success) {
    return res.status(422).json(formatZodErrors(result.error)); // theory: backend/api-design/09-error_response_design
  }
  const input = result.data; // fully typed, fully validated -- createOrder trusts it completely
  createOrder(input);
});

The schema is simultaneously runtime validation logic AND (via type inference) the compile-time TypeScript type — eliminating the class of bug where a hand-written interface and its hand-written if-check validation silently drift apart over time.

3400 vs. 422 — Malformed vs. Semantically Invalid

Matching backend/api-design/09-error_response_design's status-code discipline: a request the server can't even parse (Content-Type: application/json but the body isn't valid JSON at all) is 400; a request that parses fine but fails schema rules (quantity: -5, customerId: "not-a-number") is 422:

ts
app.use(express.json()); // throws a SyntaxError -> caught by error middleware -> 400, BEFORE your handler even runs

app.post("/orders", (req, res) => {
  const result = CreateOrderInput.safeParse(req.body); // req.body IS valid JSON by this point -- 422 territory now
  if (!result.success) return res.status(422).json(...);
});

If these two are collapsed into a single 400 for everything, a client can no longer distinguish "I sent broken JSON, a client bug" from "I sent well-formed JSON that fails your business rules" without parsing the response body — exactly the discipline error_response_design covers in depth.

Raw input passes syntactic schema validation (400 on failure) and then semantic checks that need data or context (422 on failure) before becoming a trusted DTO
Raw input passes syntactic schema validation (400 on failure) and then semantic checks that need data or context (422 on failure) before becoming a trusted DTO

4Sanitization Is a Distinct Step From Validation

Validation asks "does this input conform to the expected shape/rules" (reject if not). Sanitization transforms input into a safe/canonical form (trim whitespace, strip disallowed HTML, normalize casing) — the two are often run together but answer different questions:

ts
const CreateCommentInput = z.object({
  body: z.string()
    .trim()                                    // SANITIZE: remove leading/trailing whitespace
    .min(1, "comment cannot be empty")          // VALIDATE: reject if empty after trimming
    .transform((s) => sanitizeHtml(s, { allowedTags: ["b", "i", "a"] })), // SANITIZE: strip dangerous markup
});

Validation alone doesn't protect against injection-style attacks (a syntactically valid string can still contain a <script> tag); sanitization alone doesn't enforce business rules (a perfectly sanitized empty string is still not a valid comment). Both steps matter, and conflating "I validated it" with "it's now safe to render/store" is a common source of stored-XSS-style bugs.

5Strict Mode: Rejecting Unknown Fields

By default, most validators only check that expected fields match their rules — they silently ignore extra fields the client sent that aren't in the schema. Strict mode rejects the request outright if it contains any field not declared in the schema:

ts
const StrictCreateOrderInput = CreateOrderInput.strict(); // Zod: unknown keys now cause a validation FAILURE, not silent drop

Strict mode is a genuine security control, not just tidiness — it's a second line of defense against mass assignment (backend/api-design/10-api_security section 4): even if a handler later forgets to apply an explicit writable-field allowlist, strict-mode rejection at the validation boundary stops a client from smuggling in fields like role or isAdmin that were never declared as acceptable input in the first place.

⚠️ Common Pitfalls

  1. Validating deep inside business logic instead of at the boundary. Defeats the entire "trust your own types past this point" guarantee (section 1) — every layer ends up re-checking the same things defensively.
  2. Hand-writing both a TS interface and separate if-based validation. The two inevitably drift — a field added to the interface but not the validation (or vice versa) is a silent gap, exactly what schema-derived types (section 2) eliminate.
  3. Collapsing 400 and 422. Forces clients to inspect the response body to distinguish a client-side JSON bug from a legitimate business-rule rejection.
  4. Treating validation and sanitization as the same step. A validated-but-unsanitized string can still carry an injection payload; conflating the two is how stored-XSS bugs slip through code review that only checked "is this field the right shape."
  5. Silently accepting unknown fields by default. Without strict mode, a client can send extra fields that a careless downstream handler (missing the allowlist from 10-api_security section 4) might accidentally persist.

📋 Quick Reference

text
Validate AT THE BOUNDARY -- business logic trusts its input completely past this point
Schema + .parse()          -- one source of truth for both runtime checks AND the TS type (schema-first)
400                          -- malformed, unparseable request (bad JSON)
422                           -- well-formed request, fails semantic/business rules
Validation                     -- does this conform? (reject if not)
Sanitization                    -- transform into a safe/canonical form (trim, strip HTML, normalize)
Strict mode                      -- reject UNKNOWN fields -- a real defense-in-depth layer against mass assignment

🧠 Key takeaways

  • Validate every input (body, params, query, headers) at the edge with a schema (Zod, JSON Schema, class-validator).
  • Parse into typed values; downstream code receives trusted types.
  • 400 for malformed/unparseable, 422 for well-formed but rule-violating (be consistent).
  • Reject or strip unknown fields; sanitization (escaping, normalizing) is a separate, context-specific step.

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1Where should request validation happen?core▾

At the API boundary for shape and format (types, required fields, lengths, formats), using schemas (JSON Schema, Zod, class-validator, FluentValidation, Pydantic), and in the domain for business invariants. Never trust client-side validation alone.

Follow-ups interviewers ask
  • Why both layers? Boundary validation gives fast, clear errors; domain rules protect integrity no matter who calls.
Q2How should validation errors be returned?core▾

HTTP 400 or 422 with a consistent body listing each field error: path, message, code. RFC 9457 (problem details) with an errors extension is a good standard.

Follow-ups interviewers ask
  • Why include machine-readable codes? Clients can map them to localized messages.
Q3What's allowlist vs denylist validation?senior▾

Allowlist: accept only known-good patterns (enum values, regex for IDs), the secure default. Denylist: reject known-bad inputs, which attackers can bypass. Also strip or reject unknown fields to prevent mass assignment.

Follow-ups interviewers ask
  • What's mass assignment? Binding request fields like isAdmin directly onto models.
Q4Does validation prevent injection attacks?core▾

It helps, but the primary defences are parameterized queries, output encoding and safe APIs. Validation reduces attack surface; it's not a substitute for proper escaping.

Follow-ups interviewers ask
  • Example? A valid-looking name can still contain SQL if concatenated into a query.
Q5How do you validate large or complex payloads safely?core▾

Limit body size, depth and array lengths, set timeouts, validate content types, and avoid catastrophic-backtracking regexes (ReDoS).

Follow-ups interviewers ask
  • What's ReDoS? A regex that takes exponential time on crafted input, freezing the server.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.

✅ Check yourself

Answer the questions — feedback is instant.

1. checkOut before checkIn should return…
Semantic validation.
2. Why reject unknown fields like isAdmin?
Strict schemas.
3. Where should validation happen?
Trust inside.
4. Is HTML-escaping user input 'validation'?
Different concern.