Validate at the Boundary
One schema per endpoint, rejecting anything unexpected — the control that quietly prevents half the categories in this course.
Validation is not a nicety that produces friendly error messages. It is the control that stops mass assignment, blunts injection, bounds resource consumption, and turns a hostile payload into a 400 before it reaches a single line of business logic.
Allowlist, always
Define what is acceptable and reject everything else. A denylist — "strip <script>, block ' OR 1=1" — is a list of the attacks you thought of, and attackers are creative about encoding.
Reject unknown fields, do not strip them
Silently dropping an unexpected field hides both attacks and client bugs. An explicit 400 naming the unexpected key surfaces a probe immediately, and tells an honest client exactly what it got wrong.
Validate every dimension
| Dimension | Why |
|---|---|
| Type | {"id": {"$gt": ""}} is NoSQL injection — the field was supposed to be a string |
| Length | a 10MB name field is a memory and storage problem |
| Range | quantity: -5 refunds money; limit: 1e9 takes the database down |
| Format | a uuid, an email, an enum — not "any string" |
| Cardinality | an array of 100,000 ids is a denial of service |
| Depth | deeply nested JSON exhausts the parser before your code runs |
Where to validate
At the boundary, once, before anything else touches the data — and then use the parsed result, not the raw body. Validating req.body and then continuing to read req.body defeats the exercise entirely.
Do not validate as a substitute for escaping
Validation reduces the surface; parameterised queries and correct output encoding are what actually prevent injection. A name field legitimately contains an apostrophe, and your validator must allow it. The query must survive it.
Bound the request before parsing
Body size limits, JSON depth limits and array length limits belong before the parser. A 500MB body is rejected by a limit, not by a schema — the schema never gets to run.
Example
import { z } from 'zod';
const createOrderSchema = z.object({
items: z.array(z.object({
productId: z.string().uuid(), // format
quantity: z.number().int().min(1).max(100), // type + range
})).min(1).max(50), // cardinality
note: z.string().max(500).optional(), // length
couponCode: z.string().regex(/^[A-Z0-9]{4,16}$/).optional(),
}).strict(); // ← REJECT unknown keys
// Note what is absent: price, total, discount, userId, tenantId, status.
// Anything the server derives is not accepted from the client at all.When to use it
- A NoSQL injection is stopped because the schema required a string and the attacker sent an object.
- A 400 naming an unexpected field reveals a client bug on the day it ships, instead of silently discarding data for a month.
- An array of 200,000 ids is rejected by a cardinality limit before it can generate a query that locks the database.
More examples
A validation middleware worth reusing
Sort and order columns cannot be bound as query parameters, so an enum that maps to known column names is the correct pattern rather than escaping.
import { z } from 'zod';
export function validate({ body, query, params }) {
return (req, res, next) => {
const results = {};
for (const [key, schema] of Object.entries({ body, query, params })) {
if (!schema) continue;
const parsed = schema.safeParse(req[key]);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_failed',
location: key,
issues: parsed.error.issues.map((i) => ({
path: i.path.join('.') || '(root)',
code: i.code,
message: i.message,
})),
});
}
results[key] = parsed.data;
}
// CRITICAL: replace with the PARSED values. Coercion, defaults and the
// removal of unknown keys only take effect if downstream code reads these.
Object.assign(req, results);
next();
};
}
// Query strings are all strings — coerce explicitly and bound the result.
const listQuery = z.object({
limit: z.coerce.number().int().min(1).max(100).default(20),
cursor: z.string().max(200).optional(),
status: z.enum(['open', 'paid', 'void']).optional(),
sort: z.enum(['createdAt', 'total']).default('createdAt'), // never raw SQL
order: z.enum(['asc', 'desc']).default('desc'),
}).strict();
app.get('/api/invoices', auth, validate({ query: listQuery }), async (req, res) => {
// req.query.limit is a NUMBER, bounded to 100, with a default applied.
// req.query.sort is one of two literals, so it is safe to interpolate.
const rows = await db('invoices')
.where({ user_id: req.user.id })
.modify((q) => req.query.status && q.where({ status: req.query.status }))
.orderBy(req.query.sort, req.query.order)
.limit(req.query.limit);
res.json({ data: rows.map(InvoiceSerializer.public) });
});
// The enum on `sort` is doing security work: it is the only safe way to let a
// client influence an ORDER BY clause, which cannot be parameterised.Bounding the request before the parser runs
Capturing rawBody in the verify hook is the standard way to keep webhook signature verification possible while still using a JSON body parser.
// A schema cannot protect you from a payload that kills the parser first.
// 1. Body size — the outermost limit
app.use(express.json({
limit: '100kb', // most APIs need far less than 1mb
strict: true, // only objects and arrays at the root
verify: (req, res, buf) => { req.rawBody = buf; }, // for webhook signatures
}));
// Per-route override where a larger body is genuinely needed
app.post('/api/documents', express.json({ limit: '5mb' }), createDocument);
// 2. JSON depth — {"a":{"a":{"a": ... }}} 50,000 deep exhausts the stack
function depthLimit(max = 20) {
return (req, res, next) => {
const depth = (v, d = 0) => {
if (d > max || v === null || typeof v !== 'object') return d;
return Math.max(d, ...Object.values(v).map((c) => depth(c, d + 1)));
};
if (depth(req.body) > max) {
return res.status(400).json({ error: 'payload_too_deep' });
}
next();
};
}
// 3. Key count — {"a1":1,"a2":1, ... } with a million keys
function keyLimit(max = 200) {
return (req, res, next) =>
Object.keys(req.body ?? {}).length > max
? res.status(400).json({ error: 'too_many_fields' })
: next();
}
app.use(depthLimit(20), keyLimit(200));
// 4. Prototype pollution — reject the poisoning keys during parse
app.use(express.json({
reviver: (key, value) =>
['__proto__', 'constructor', 'prototype'].includes(key) ? undefined : value,
}));
// 5. Content-Type — refuse what you do not handle
app.use((req, res, next) => {
if (!['POST', 'PUT', 'PATCH'].includes(req.method)) return next();
const ct = (req.get('content-type') ?? '').split(';')[0].trim();
if (!['application/json', 'multipart/form-data'].includes(ct)) {
return res.status(415).json({ error: 'unsupported_media_type' });
}
next();
});
// Ordering matters: size → content-type → parse → depth → schema.
// Each layer protects the one after it.The same discipline in PHP and Python
extra='forbid' in Pydantic and the withValidator check in Laravel are the equivalents of Zod's .strict() — none of the three reject unknown keys by default.
<?php
// Laravel — the rules ARE the allowlist, and validated() returns ONLY them.
public function store(Request $request)
{
$data = $request->validate([
'items' => ['required', 'array', 'min:1', 'max:50'],
'items.*.productId' => ['required', 'uuid'],
'items.*.quantity' => ['required', 'integer', 'min:1', 'max:100'],
'note' => ['nullable', 'string', 'max:500'],
'couponCode' => ['nullable', 'string', 'regex:/^[A-Z0-9]{4,16}$/'],
]);
// ✅ $data contains ONLY the validated keys — this is the allowlist.
// ❌ $request->all() would include everything the attacker sent.
$order = Order::create([
...$data,
'user_id' => $request->user()->id, // from the credential, not input
'total' => $this->calculateTotal($data['items']), // server-computed
]);
return new OrderResource($order); // explicit output shape
}
// Reject unknown keys explicitly — Laravel ignores them by default.
public function rules(): array { /* ... */ }
public function withValidator($validator): void
{
$validator->after(function ($v) {
$unknown = array_diff(array_keys($this->all()), array_keys($this->rules()));
if ($unknown) {
$v->errors()->add('body', 'Unexpected fields: ' . implode(', ', $unknown));
}
});
}
# ------------------------------------------------------------------
# Python — Pydantic v2
# from pydantic import BaseModel, Field, ConfigDict
#
# class OrderItem(BaseModel):
# model_config = ConfigDict(extra='forbid') # ← reject unknown keys
# product_id: UUID
# quantity: int = Field(ge=1, le=100)
#
# class CreateOrder(BaseModel):
# model_config = ConfigDict(extra='forbid')
# items: list[OrderItem] = Field(min_length=1, max_length=50)
# note: str | None = Field(default=None, max_length=500)
#
# @app.post('/api/orders')
# async def create(order: CreateOrder, user = Depends(current_user)):
# ... # FastAPI validates before the handler body runs at all
Discussion