Transport Validation
The only check that needs no state: is this payload the right shape, the right types, and small enough to look at?
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
What can a schema at the edge actually guarantee, and what does it silently let through?
A public endpoint accepts JSON. Some callers are our own web app, some are a mobile build from eight months ago, and some are partner integrations written against a PDF.
Read the fields you need off req.body and check the ones that matter with a few if statements. Anything else is over-engineering for a payload with four fields.
quantity arrives as the string "2" from one client and the number 2 from another. total = price * quantity works for both and quantity + 1 produces "21" for one of them.
quantityarrives as the string"2"from one client and the number2from another.total = price * quantityworks for both andquantity + 1produces"21"for one of them.- A field is absent rather than wrong, so
undefinedflows through three layers and lands in the database asNULLin a column that is nominally required in the domain but not in the schema. - The body contains
role: "admin"and reachesdb.user.update({ data: body }). Nothing was invalid; everything was extra (Mass Assignment and Over-Posting). - A 60 MB JSON array is parsed into memory before any check runs, because the body was parsed by middleware before the handler could look at it (Request Bodies and Streaming).
- A partner sends
"2026-03-15"where an ISO timestamp was expected.new Date()accepts it, interprets it as UTC midnight, and every event lands on the wrong day for callers in UTC+13. - Checks are duplicated at four endpoints and drift, so the same field has four definitions of valid.
What is actually happening
- Transport validation is the only layer that is a pure function of the payload. No queries, no session, no clock. That is what lets it run first, run cheaply, and be shared with a generated client (OpenAPI: Describing the Contract, Not Designing It).
- It answers three separate questions that get lumped together: presence (is the field there), type (is it the right kind of thing), and domain (is it in range, matching a format, a member of an enum).
- A schema is also a shape contract: what happens to fields you did not declare — strip, reject, or pass through — is a decision with a security consequence, not a default to accept (Mass Assignment and Over-Posting).
- Coercion is a policy, not a convenience. Accepting
"2"for a number is fine at a public boundary and dangerous where the string form is meaningful, and either way it should be a deliberate setting rather than a library default. - Order matters: the size limit runs before parsing, because parsing is what allocates. A check inside the handler runs after the framework has already materialised the body.
- What it fundamentally cannot answer: anything requiring state. "This email is not taken", "this invite is open", "this user may do this" are outside its reach by construction (The Three Validations).
A schema that produces a type, not a boolean
model_config = ConfigDict(extra='forbid'), and in Go a struct with json tags plus decoder.DisallowUnknownFields(). The three differ in coercion defaults — Pydantic v2 coerces "2" to 2 in non-strict mode, Zod does not without z.coerce, and Go refuses outright.The valuable property of a schema is not that it rejects bad input — a hand-written if does that too. It is that the checked output has a type the compiler knows about, so nothing below the boundary has to ask again whether quantity is a number.
The details that matter in the example below are the unglamorous ones: .strict() decides mass assignment, .max() on the array decides whether a caller can allocate 400 MB, and the fact that CreateOrder is *derived* from the schema means the type and the check cannot drift apart.
1import { z } from 'zod'2 3const CreateOrder = z.object({4 customerId: z.string().uuid(),5 currency: z.enum(['EUR', 'USD', 'GBP']), // membership, not just "a string"6 items: z.array(z.object({7 sku: z.string().regex(/^[A-Z0-9-]{3,32}$/), // anchored, bounded, no backtracking8 quantity: z.number().int().positive().max(999),9 })).min(1).max(100), // an unbounded array is an allocation10 note: z.string().max(500).optional(),11}).strict() // unknown fields REJECTED, not passed on12 13type CreateOrder = z.infer<typeof CreateOrder> // the type comes from the schema14 15function parseCreateOrder(body: unknown): Result<CreateOrder, FieldError[]> {16 const r = CreateOrder.safeParse(body) // no throw: failure is a value17 return r.success18 ? { ok: true, value: r.data }19 : { ok: false, errors: r.error.issues.map((i) => ({20 path: i.path.join('.'), code: i.code, // stable code, NO value echoed back21 })) }22}.strict() is the security-relevant line: with the default .strip() the extra fields are silently dropped, which is usually right for a public API, and with .passthrough() they reach your ORM. Note also what is absent — no query for an existing customer. That is the next layer (Business Validation).
The unknown-field decision
Every schema library has a default for fields you did not declare, and almost nobody chooses it deliberately. It is worth choosing: one option is a silent data-loss bug for clients, one is a compatibility hazard, and one is a privilege-escalation vector.
The failure at the bottom of the table is the one that shows up in incident reports. It does not require an unvalidated request — it requires a validated request whose extra fields were kept.
Who are your callers, and what should a typo cost them?
when Public APIs and many clients. Additive changes stay safe, and old clients sending removed fields keep working.
cost A client typo — emailAddress for email — succeeds and does nothing. The support ticket says "it saved but the field is empty".
when Internal APIs and first-party clients, where a typo should fail loudly and immediately.
cost Any client sending a field you later remove now breaks. Removing a field becomes a breaking change (Removing Fields Without Removing Consumers).
when Almost never for a request body. Occasionally for a proxy or a store-and-forward transform that must not lose data.
cost Whatever the caller sent reaches your ORM. This is how role: "admin" and isVerified: true get written (Mass Assignment and Over-Posting).
when You want lenient input and a hard guarantee at the write. The safest combination for update endpoints.
cost Two places to keep in sync — the schema and the write-field list.
What still gets through
A schema is a filter with a specific mesh size, and knowing what passes through it is more useful than knowing what it catches. Each row below is a request that a correct, strict schema accepts.
The pattern is consistent: transport validation guarantees the input *could* mean something. Whether it means something permitted, and whether the resulting state is consistent, are questions it never asked.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
customerId is a well-formed UUID for another tenant's customer | Cross-tenant data access with a 200 response | A valid format is not a valid reference, and never an authorization decision | Object-level check against the authenticated principal (Object-Level Authorization) |
email is alice@example.com, already registered | Duplicate account, or a 500 from a unique index | Uniqueness is a property of stored state, not of the string | Unique constraint plus a caught violation (Database Constraints) |
status: "shipped" on an order that was cancelled | An illegal state transition recorded as fact | Enum membership says nothing about the current state | A transition check in the service (Business Validation) |
quantity: 999 when only 3 are in stock | Oversell | Range validation does not know about inventory | Atomic conditional decrement, not a read-then-write (Atomic Operations) |
note is 500 characters of <script> | Stored XSS in an admin panel | Length and type are satisfied; content is not the schema's concern | Encode at output; validation is not the defence here (XSS Defense by Output Context) |
| 100 valid items, sent 50 times per second by one client | The service is fine and the database is saturated | Per-request limits do not bound aggregate rate | Rate limiting and quotas (Rate Limiting) |
How to build it
Most important first.
- Declare one schema per endpoint and derive the type from it, so the schema and the type cannot disagree (Parse, Do Not Validate).
- Set a body-size limit per route against real payloads, not one global default. An avatar endpoint and a bulk-import endpoint have nothing in common (Resource Limits).
- Decide the unknown-field policy explicitly and write down why. Strip for public APIs, reject for internal ones where a typo should be loud.
- Validate query and path parameters with the same rigour as the body. They are strings from the URL, they reach the same code, and they are routinely skipped (Query Parameters, Path Parameters).
- Bound every collection: an array field without a maximum length is an unbounded allocation (Unbounded Concurrency).
- Keep business rules out of the schema. A "refine" that queries the database turns validation into I/O and makes the schema uncacheable, unshareable and slow.
- Return all field errors at once rather than the first, and use stable machine-readable codes (Reporting Validation Failures).
What can go wrong
- Validating the body while ignoring query, path, headers and cookies (Every Input Surface).
- Coercion turning a mistake into a value:
Number("")is0,Boolean("false")istrue, and both pass a type check. - Optional fields with defaults applied at the schema, so a client that omits a field silently gets behaviour it did not choose.
- Validating after the expensive work — a schema check that runs after authentication and a database load, so invalid requests cost the same as valid ones (Middleware Ordering Is a Correctness Decision).
- Regex-based format checks that backtrack catastrophically on a crafted input, turning a validator into a CPU denial of service (Blocking the Event Loop).
- A schema so strict that adding an optional field to a request breaks every old client, on an API where that was supposed to be safe (Backward Compatibility: The Real Rules).
- Nothing here races — that is the defining property of this layer, and the reason it is the only one you can cache, share with a client, or run in a gateway.
- The exception is a schema that reaches out for state. A "refine" that checks uniqueness has left this layer and inherited the check-then-act gap it looked like it avoided (Database Constraints).
- Field allowlisting is the mass-assignment defence. Passing a validated-but-unstripped object into an ORM update is the same vulnerability with a schema in front of it (Mass Assignment and Over-Posting).
- Size and depth limits are denial-of-service defences: deeply nested JSON is a parser cost, and a large array is an allocation (Request Bodies and Streaming).
- Type strictness prevents whole classes of injection at the boundary — an object where a string was expected is how NoSQL operator injection and prototype pollution arrive.
- Transport validation is not injection defence on its own. Parameterised queries and encoded output are still required, because a perfectly well-formed string can still be a payload (SQL Injection, Defence in Depth).
- Never echo the rejected value back in the error. It may be a password, a token, or someone's personal data (Secrets in Logs).
- "The schema validated it, so the data is valid." It is well-formed. Whether it is permitted or consistent are separate questions (Business Validation, Database Constraints).
- "Types make validation unnecessary." A TypeScript type is erased at runtime.
JSON.parse(body) as CreateUserasserts a shape and checks nothing (Parse, Do Not Validate). - "Validation prevents SQL injection." Parameterisation prevents SQL injection. Validation reduces the surface (SQL Injection).
- "Validate everything as strictly as possible." Strictness is a contract decision with a compatibility cost, and public APIs pay it repeatedly.
- "OpenAPI validates my requests." A specification describes them. Whether anything enforces it at runtime is a separate deployment fact (OpenAPI: Describing the Contract, Not Designing It).
Operating it
- Count rejections by field path and error code. A single field dominating the counter is a documentation or client bug, not an attack.
- Break rejections down by client version or user agent. A step change after a client release is a contract mismatch you can fix by talking to someone (Deploys Are the First Suspect).
- Track the p99 body size per route. Validation cost and memory pressure both track it, and it moves without anyone deciding to move it.
- Alert on 413s. They mean a limit is being hit by a real caller, which is either an attack or a legitimate use case you did not size for.
- At 10x request rate schema validation is still cheap relative to a single query. It shows up in a profile only for large payloads or pathological regexes.
- At 100x payload size the cost is parsing and allocation, not the schema, and the fix is streaming or a size cap rather than a faster validator (What Serialization Costs).
- At 10x number of clients, strictness becomes a support cost. What is a healthy failed-fast contract with three internal callers is an integration burden with three hundred partners.
- Strict schemas break clients on changes that would otherwise be additive and safe. Postel-style leniency is more forgiving and lets bad data further in (Backward Compatibility: The Real Rules).
- Coercion improves compatibility with sloppy clients and hides genuine client bugs behind a value that happens to work.
- One schema per endpoint means duplication between similar endpoints. Sharing them couples endpoints so that one contract change moves two APIs.
- Detailed field errors are excellent for developers and a small information disclosure on an authentication-adjacent endpoint, where "no such field" and "wrong value" are distinguishable signals.
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- GENERALShape checking at the process edge, before anything expensive, applies to every backend regardless of stack.
- FRAMEWORK-SPECIFICWhere the body is parsed differs and it changes what a size limit can do: Express parses in
body-parsermiddleware, so a limit set in the handler is already too late; FastAPI validates via Pydantic during request model binding, so the check happens before your function runs; Go'sencoding/jsondecodes where you call it, sohttp.MaxBytesReaderis yours to install. The same schema is enforced at a different moment in each. - LANGUAGE-SPECIFICStatic types help unevenly. In TypeScript types are erased, so runtime validation is mandatory and libraries like Zod exist to infer the type from the schema. In Go a struct tag plus
encoding/jsongives type enforcement but ignores unknown fields unlessDisallowUnknownFieldsis set. Python has no compile-time enforcement at all, which is why Pydantic became a de facto part of the request path.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.