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.
🎯 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.
// 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:
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 truthapp.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:
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.
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:
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:
const StrictCreateOrderInput = CreateOrderInput.strict(); // Zod: unknown keys now cause a validation FAILURE, not silent dropStrict 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
- 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.
- Hand-writing both a TS
interfaceand separateif-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. - Collapsing
400and422. Forces clients to inspect the response body to distinguish a client-side JSON bug from a legitimate business-rule rejection. - 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."
- 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_securitysection 4) might accidentally persist.
📋 Quick Reference
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
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.
- 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.
- 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.
- What's mass assignment? Binding request fields like
isAdmindirectly 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.
- 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).
- 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.