The trust boundary pattern in TypeScript backends
Every request payload an API accepts is, from TypeScript's point of view, unknown at first, no matter how trustworthy the client appears. This article shows how the trust boundary pattern consistently anchors validation at the system boundary, with middleware that validates before every handler, typed request objects throughout the rest of the code, and structured error propagation that prevents unchecked data from reaching deeper into the application.
Table of Contents
- 1. The trust boundary concept: where trust ends
- 2. Why a request body should never be treated as typed
- 3. Building a validation middleware for all routes
- 4. Typed handler signatures after validation
- 5. Consistently including query params, path params and headers
- 6. Structured error propagation instead of generic 500s
- 7. Treating internal service boundaries like external ones
- 8. Common mistakes when implementing trust boundaries
- 9. Boundary validation vs. scattered checks compared
- 10. Summary
- 11. FAQ
1. The trust boundary concept: where trust ends
A trust boundary is the point in an architecture where data moves from an area of lower trust into an area of higher trust, for example from the public internet into the core logic of a backend. Everything on the far side of that boundary must be treated as potentially malformed, tampered with, or simply structured incorrectly, regardless of how trustworthy a client normally appears.
The trust boundary pattern for TypeScript APIs translates this security concept into a concrete architecture rule: validation happens exclusively at the boundary, right before data enters the interior of the application, never scattered across multiple layers. Once a value has passed this boundary validated, the rest of the code can rely on the checked TypeScript type, without distrusting it again at every further point.
2. Why a request body should never be treated as typed
In many Express or Fastify projects, a request body is given a generic type parameter like Request<{}, {}, CreateOrderDto>, even though req.body is actually any at runtime, a pure framework promise without any checking behind it. This type annotation only describes what the developer expects, not what actually arrives, a difference that becomes immediately visible with a malformed client, an old API consumer, or a tampered request.
The correct starting point at every API boundary is therefore to treat the request body explicitly as unknown until a real runtime validation has taken place. This one mental shift, treating raw data as fundamentally unknown rather than already typed, is the basic requirement for a working trust boundary pattern and prevents the widespread practice of establishing type safety through mere assertion instead of actual checking.
3. Building a validation middleware for all routes
Instead of repeating validation in every single route handler, a generic middleware bundles the check at one central point that every route passes through before the actual handler logic is reached. This middleware takes a Zod schema as a parameter, validates req.body against it, and terminates the request right at the boundary on failure, with a structured 400 response instead of an unclear error deep inside the business logic.
This pattern turns validation into a declarative property of every route, visible directly in the route definition, instead of hidden somewhere in the handler code. A new endpoint gets validation essentially for free as soon as it registers the same middleware with its own schema, a consistent pattern across the entire API.
import { z, type ZodType } from "zod";
import type { Request, Response, NextFunction, RequestHandler } from "express";
// Generic middleware: validates req.body against any Zod schema
function validateBody<T>(schema: ZodType<T>): RequestHandler {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
res.status(400).json({
error: "ValidationError",
issues: result.error.flatten().fieldErrors,
});
return;
}
// Attach the validated, typed data instead of trusting req.body directly
(req as Request & { validated: T }).validated = result.data;
next();
};
}
const createOrderSchema = z.object({
customerId: z.string().uuid(),
items: z.array(
z.object({ sku: z.string(), quantity: z.number().int().positive() })
),
});
// Registered per route: validation is visible right in the route definition
app.post("/orders", validateBody(createOrderSchema), createOrderHandler);
4. Typed handler signatures after validation
Once the middleware has successfully validated a request, the actual handler should access the checked, typed data instead of reaching for req.body again. A generic helper type that extends the handler signature with the already validated type makes this guarantee visible in the type system and prevents a handler from accidentally accessing the unchecked raw value.
This step closes the loop of the trust boundary pattern: the middleware is the only place that ever sees unknown data, the handler works exclusively with an already checked, specific type. For the rest of the application code, service classes, repository calls, business logic, the problem of unvalidated input simply does not exist at this point anymore.
import { z } from "zod";
import type { Request, Response } from "express";
const createOrderSchema = z.object({
customerId: z.string().uuid(),
items: z.array(
z.object({ sku: z.string(), quantity: z.number().int().positive() })
),
});
type CreateOrderInput = z.infer<typeof createOrderSchema>;
// Typed request: "validated" is guaranteed to exist and match the schema
type ValidatedRequest<T> = Request & { validated: T };
function createOrderHandler(
req: ValidatedRequest<CreateOrderInput>,
res: Response
): void {
// No further validation needed: req.validated is a trusted CreateOrderInput
const { customerId, items } = req.validated;
const total = items.reduce((sum, item) => sum + item.quantity, 0);
res.status(201).json({ customerId, itemCount: items.length, total });
}
5. Consistently including query params, path params and headers
The trust boundary pattern is not limited to the request body. Query parameters always arrive as strings, even if they logically represent a number or a boolean, path parameters likewise, and headers can be missing entirely or sent multiple times with different values. Each of these sources is its own, independent boundary to the outside world and deserves its own schema, instead of being silently assumed correct.
An extended middleware therefore validates req.query, req.params, and relevant headers each against a dedicated Zod schema, with explicit coercion for numbers and booleans via z.coerce.number() and z.coerce.boolean() respectively. This consistency prevents the common gap where a body is carefully validated while a critical ID in the path is passed straight into a database query unchecked.
import { z } from "zod";
// Query params always arrive as strings, even for numeric or boolean values
const listOrdersQuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
includeArchived: z.coerce.boolean().default(false),
});
// Path params equally need explicit validation, not just a type annotation
const orderParamsSchema = z.object({
orderId: z.string().uuid(),
});
type ListOrdersQuery = z.infer<typeof listOrdersQuerySchema>;
type OrderParams = z.infer<typeof orderParamsSchema>;
function validateListOrdersRequest(rawQuery: unknown, rawParams: unknown) {
const query: ListOrdersQuery = listOrdersQuerySchema.parse(rawQuery);
const params: OrderParams = orderParamsSchema.parse(rawParams);
return { query, params };
}
6. Structured error propagation instead of generic 500s
A common architecture mistake is letting validation errors at the boundary simply pass through until they end up somewhere as a generic 500 error or an unhandled exception. The trust boundary pattern instead requires that every validation error class be translated right at the boundary into an appropriate HTTP response, typically 400 for structurally invalid input, with a consistent, machine readable error structure.
This structured response should name, per field, exactly what was invalid, so a calling client or a frontend form can use the error message directly instead of receiving a cryptic stack trace. Zod's error.flatten().fieldErrors delivers exactly this structure out of the box, without the application having to design its own error format from scratch.
7. Treating internal service boundaries like external ones
An often overlooked aspect of the trust boundary pattern is that not only the public API boundary to the client matters, but also internal boundaries between microservices, between a backend and a message queue, or between two modules with different deploy cycles inside the same monolith. The same applies here: another service can implement a different version of the contract, omit a field, or unexpectedly send null instead of a string.
Anyone who enforces validation only at the outermost, public boundary but blindly trusts internal service to service calls merely shifts the risk instead of removing it. Response schemas for internal API calls, validated with the same trust boundary principle as incoming requests, surface version inconsistencies between services before they show up as a hard to trace runtime error in production.
8. Common mistakes when implementing trust boundaries
The most common mistake is a type annotation without real checking, a Request<{}, {}, CreateOrderDto> that nobody ever follows up with a safeParse() or parse() call. The second common mistake is duplicated but inconsistent validation, where a frontend form checks its own rules and the backend checks its own, non identical rules, so a record passes the frontend but gets rejected by the backend, or the other way around.
A third, more subtle mistake is validating only the body while leaving query parameters, path parameters, and headers unvalidated, on the assumption that they are less critical. Yet path parameters like an orderId frequently end up directly and unchecked in a database query, which makes them a particularly sensitive part of the trust boundary, not a secondary one.
9. Boundary validation vs. scattered checks compared
The following table contrasts consistent boundary validation with scattered, ad hoc inserted checks, as they commonly appear in grown codebases without a deliberate trust boundary pattern.
| Aspect | Scattered checks | Trust boundary pattern |
|---|---|---|
| Location of validation | Spread across handler, service, repository | Centralized in middleware at the boundary |
| Consistency of rules | Easily contradictory across layers | One schema per boundary, one source of truth |
| Error responses | Inconsistent, sometimes 500 instead of 400 | Structured, consistent, with fieldErrors |
| Query/path parameters | Often overlooked, passed straight to the DB | Own schema, explicit coercion |
| Internal service boundaries | Usually unchecked, blindly trusted | Same principles as the public boundary |
A consistent trust boundary pattern turns validation from a collection of individual decisions into an architectural constant that automatically applies to every new route.
Mironsoft
Trust boundary middleware, API hardening and request validation for your backends
Bring consistent validation to your API boundaries?
We build a central validation middleware for body, query and path parameters, set up typed handlers, and close gaps at internal service boundaries.
Boundary audit
Scanning all endpoints for unchecked input and gaps
Middleware build out
Central, reusable validation for body, query and params
Internal contracts
Securing service to service boundaries with the same principle
10. Summary
The trust boundary pattern solves a structural problem that a plain type annotation can never solve: a request body, a query parameter, or a path parameter is always unknown at runtime, regardless of what the compiler claims. Consistently enforcing validation at API boundaries means bundling this check at a central middleware, before data ever enters the actual business logic.
Typed handler signatures after successful validation make the guarantee visible in the type system, structured error responses with field level detail replace generic 500s, and the same principle should be applied consistently to internal service to service boundaries too, not just the public API. Once this boundary is drawn cleanly, the rest of the code never has to distrust the data again.
Enforcing Validation at API Boundaries, the key points at a glance
Define the trust boundary
Everything beyond the boundary is unknown, regardless of the type annotation.
Central middleware
Check body, query and path parameters through a generic validation middleware.
Typed handlers
The checked type applies after the boundary, no re-checking needed in the handler.
Include internal boundaries
Service to service calls deserve the same rigor as public endpoints.