The Boundary Defense Principle: Trust Is Not Transitive
By Sergey Nosov
14 October 2026
On 19 July 2024, a content update from CrowdStrike crashed Windows computers around the world; Microsoft estimated that it affected 8.5 million devices. The update was a channel file, whose content CrowdStrike’s root cause analysis calls “configuration data; it is not code or a kernel driver.” The vendor’s own validator had checked it.
The validator “based its assessment on the expectation that the IPC Template Type would be provided with 21 inputs,” as the template type’s definition said. The Falcon sensor, CrowdStrike’s agent on each computer, supplied only twenty, and its interpreter had no bounds check of its own. When the file used the twenty-first input, “the attempt to access the 21st value produced an out-of-bounds memory read beyond the end of the input data array and resulted in a system crash.” The report lists six findings, from a missing test case to the lack of a staged rollout, and the fixes it describes include adding the missing bounds check.
The Boundary Defense Principle makes the consumer’s check the rule. Treat every interface between components as a trust boundary, and let the code that consumes data check it, whoever produced it. Trust is not transitive. A service may trust its neighbor, but when the neighbor passes along data it received from somewhere else, that trust does not establish that the data meets the service’s own rules. At each boundary, data is validated, normalized, and, where it is about to reach an interpreter, sanitized.
The samples below are TypeScript; Node.js 24 runs them directly by stripping the types.
Where the Boundaries Are
The obvious boundaries face the outside world: HTTP endpoints that receive data from browsers and mobile apps, file uploads, and responses from third-party APIs. The less obvious ones sit inside the system:
- Between services: a payment service calls a user service, or consumes events from a queue or a stream.
- Between layers: a controller hands data to a service, and the service reads rows back from the database.
- Between a process and its environment: environment variables, configuration files, feature files, and caches.
- Between modules: one module exports a function and another calls it, so a refactoring upstream can quietly break an assumption downstream.
Full checks pay off most where data arrives from code that was not built, tested, and deployed together with the code receiving it. That covers another service, another version of your own service, a configuration pipeline, and the past: a row in the database was written by whatever code was running when it was stored. Inside one program that is built and shipped as a unit, the boundaries between modules are real but cheaper to defend. The compiler and the tests carry most of the load, and assertions cover the rest.
Threat modeling draws the same lines on a diagram, as the STRIDE article shows; this article is about what the code on each side of them does.
The principle has its own entry in my Software Development Principles series, with indicators of proper application and of common violations.
Why Check Data Your Own System Produced
Checking data that your own code produced sounds paranoid until you list the ways internal data goes wrong. Three stand out.
Components get compromised. NIST’s SP 800-207, published in August 2020, states the assumption behind zero trust: “Zero trust assumes there is no implicit trust granted to assets or user accounts based solely on their physical or network location.” The same reasoning applies to data. A message is not trustworthy because it came from inside the network or from a service your company wrote. If an attacker takes over one service, every service that accepts its requests unchecked extends the attacker’s reach. OWASP’s Microservices Security Cheat Sheet draws the line for authorization: “Each service must enforce access to its protected operations, including internal calls.”
Data drifts and decays. Schemas change, migrations have bugs, caches go stale, and queues deliver duplicates. On 18 November 2025, Cloudflare had what it called its “worst outage since 2019,” and its postmortem traces it to a permissions change in a ClickHouse database. A query that built the feature file for Cloudflare’s Bot Management did not filter by database name, so after the change it returned duplicate rows. The file ended up with more than 200 features, and the proxy that reads it preallocates memory for only 200. The first item on Cloudflare’s remediation list reads like a statement of this principle: “Hardening ingestion of Cloudflare-generated configuration files in the same way we would for user-generated input.”
Upstream checks have bugs, and they answer someone else’s question. CrowdStrike’s validator was a real check that tested the wrong expectation. Even a correct upstream check answers the producer’s question, which is not necessarily yours. A profile service can confirm that an address is well formed without knowing that your carrier cannot deliver there. OWASP’s Input Validation Cheat Sheet, revised in October 2026, lists “Trusting internal sources” among its common pitfalls: “An internal transport does not establish that a value meets the receiving component’s rules.”
Suppose a catalog service validates product IDs at its API, and an order service receives those IDs through an event stream. After a faulty database migration, the catalog starts emitting malformed IDs. An order service that trusts the stream crashes on the first bad event, and orders fail until someone notices. One that parses every event rejects the bad ones and keeps working. The mistake in the first design is not a lack of validation somewhere. It is the belief that validation elsewhere was enough.
Three Jobs at Every Boundary
Boundary defense breaks into three jobs, each answering one question:
- Validation: is this data what I expect?
- Normalization: are we talking about the same thing?
- Sanitization: could this data be weaponized where I am about to send it?
In code, the order differs from the list. Cheap checks on type and size come first, so that nothing expensive runs on oversized input. Normalization comes next, and the acceptance checks then run on the normalized form, the one you will actually use. Sanitization runs last, on the way out, at the point where data meets an interpreter, because only there do you know which interpreter it is.
Validate: Is This What I Expect?
Validation checks type, range, format, and the invariants that tie fields together, and it rejects anything that fails. It does not repair: a negative amount is not quietly made positive, and a missing currency is not filled in with a default. For each field you use, define the values you accept and refuse the rest, rather than hunting for bad patterns. OWASP’s input validation sheet is blunt: “Reject invalid requests with a clear error; do not continue with partially validated data.” And bound the input before you parse it, because, as the same sheet warns, “a schema check after parsing cannot protect a parser that has already exhausted resources.”
The cleanest way to validate is to parse: take the untrusted input and build a new, typed object from the fields you checked. Here is a parser for an order event, saved as parse-order.ts; the later samples import its ValidationError.
export class ValidationError extends Error {}
const CURRENCIES = ["USD", "EUR", "GBP"] as const;
export type OrderLine = Readonly<{
sku: string;
quantity: number;
}>;
export type Order = Readonly<{
orderId: string;
currency: (typeof CURRENCIES)[number];
amountCents: number;
lines: readonly OrderLine[];
}>;
const ORDER_ID = /^ORD-\d{8}$/;
const SKU = /^[A-Z0-9-]{1,32}$/;
function fail(message: string): never {
throw new ValidationError(message);
}
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
function isInt(v: unknown, min: number, max: number): v is number {
return typeof v === "number" && Number.isSafeInteger(v)
&& v >= min && v <= max;
}
export function parseOrder(input: unknown): Order {
if (!isRecord(input)) fail("order: not an object");
const { orderId, currency, amountCents, lines } = input;
if (typeof orderId !== "string" || !ORDER_ID.test(orderId)) {
fail("orderId: bad format");
}
const checkedCurrency = CURRENCIES.find((c) => c === currency)
?? fail("currency: not supported");
if (!isInt(amountCents, 1, 100_000_000)) {
fail("amountCents: out of range");
}
if (!Array.isArray(lines) || lines.length < 1
|| lines.length > 100) {
fail("lines: need 1 to 100");
}
// Array.from, not map: map skips
// the holes in a sparse array.
const checkedLines = Array.from(lines, (line: unknown, i): OrderLine => {
if (!isRecord(line)) fail(`lines[${i}]: not an object`);
const { sku, quantity } = line;
if (typeof sku !== "string" || !SKU.test(sku)) {
fail(`lines[${i}].sku: bad format`);
}
if (!isInt(quantity, 1, 999)) {
fail(`lines[${i}].quantity: out of range`);
}
return Object.freeze({ sku, quantity });
});
// A new object holds only the
// checked fields; anything else
// in the input stays behind.
return Object.freeze({
orderId,
currency: checkedCurrency,
amountCents,
lines: Object.freeze(checkedLines),
});
}
Given a good order and three broken ones, it accepts the first and names the first problem in each of the others:
import { parseOrder, ValidationError } from "./parse-order.ts";
const valid = {
orderId: "ORD-20261014",
currency: "USD",
amountCents: 4999,
lines: [{ sku: "BOOK-1", quantity: 1 }],
isAdmin: true,
};
console.log(parseOrder(valid));
// Prints:
// {
// orderId: 'ORD-20261014',
// currency: 'USD',
// amountCents: 4999,
// lines: [ { sku: 'BOOK-1',
// quantity: 1 } ]
// }
const broken = [
{ ...valid, amountCents: "4999" },
{ ...valid, lines: undefined },
{ ...valid, lines: [{ sku: "x" }] },
];
for (const input of broken) {
try {
parseOrder(input);
} catch (err) {
if (!(err instanceof ValidationError)) throw err;
console.log(err.message);
}
}
// Prints:
// amountCents: out of range
// lines: need 1 to 100
// lines[0].sku: bad format
Three details carry more weight than they appear to. The amount must be an integer, not a string that happens to compare like a number. In JavaScript, "4999" <= 0 is simply false, so a range check alone would let the string through. The lines must be an array before anything reads their length. And the parser returns a new frozen object, so the isAdmin flag in the input never reaches the rest of the system. OWASP calls that failure mode mass assignment and says to “bind only intended input fields to application objects.” Roles and permissions come from the authenticated identity on the server, never from a payload.
Ignoring unknown fields also keeps the parser working when a producer adds fields later, which is what Martin Fowler’s Tolerant Reader asks for: “only take the elements you need, ignore anything you don’t.” Strictness and tolerance fit together here: be strict about every field you use, ignore the ones you do not, and never pass the input itself along. Rejecting unknown fields instead is a defensible choice for a public API, where it catches a client’s typo early. The canonical data model article covers where translation code like this belongs.
The error messages name the field but never echo the value. That keeps raw input out of the logs, where OWASP says to record “the failure and relevant metadata without secrets or full request bodies.”
Normalize: Are We Talking about the Same Thing?
The same value can arrive in several forms: different letter case, different Unicode sequences for the same character, or a path with .. in it. Normalization converts it to one canonical form, and the checks that decide acceptance have to run after it. MITRE lists the reverse order as a weakness of its own, CWE-180: “The product validates input before it is canonicalized, which prevents the product from detecting data that becomes invalid after the canonicalization step.” Its example is the path /safe_dir/../, which passes a prefix check and canonicalizes to the root directory.
For file paths, canonicalize by resolving the name against the base directory, then check that the result is still inside it. Normalizing alone is not enough: a resolved path can be perfectly tidy and still point at /etc/passwd.
import path from "node:path";
import { ValidationError } from "./parse-order.ts";
export function resolveInside(base: string, name: string) {
const root = path.resolve(base);
// Canonicalize first: resolve
// collapses "." and "..".
const full = path.resolve(root, name);
// Then check the result.
const rel = path.relative(root, full);
if (rel === "" || rel === ".." || path.isAbsolute(rel)
|| rel.startsWith(".." + path.sep)) {
throw new ValidationError("path: outside the base");
}
return full;
}
for (const name of ["2026/report.pdf", "../etc/passwd",
"a/../../etc/passwd", "/etc/passwd"]) {
let verdict = "ok";
try {
resolveInside("uploads", name);
} catch {
verdict = "rejected";
}
console.log(verdict, name);
}
// Prints:
// ok 2026/report.pdf
// rejected ../etc/passwd
// rejected a/../../etc/passwd
// rejected /etc/passwd
The same function handles Windows paths, including drive letters and network shares, because path.relative returns an absolute path when the target sits on another root. The check is lexical, though: it sees the name, not the file system. If the directory can contain symbolic links, resolve the base and the existing target with fs.realpath, which resolves the links as well, and check again. Even then, a link swapped in between the check and the open redirects the open. Where untrusted users can create links there, containment has to come from the operating system, such as a sandbox or container that cannot see the rest of the file system.
For identities, normalization decides who is who, so its rules deserve more thought than a call to toLowerCase(). For email, RFC 5321 says that “mailbox domains follow normal DNS rules and are hence not case sensitive.” The part before the at sign is different: it “MUST BE treated as case sensitive,” though exploiting that “impedes interoperability and is discouraged.” Plus addressing, where mail to ann+news@example.com reaches Ann, is a common convention that not every mail system follows. So lowercasing a whole address and dropping the tag after a plus sign are policy decisions, not normalization. Keep the address exactly as given for sending mail, and compare a separate key built by rules you chose on purpose.
Normalization also has to be idempotent: applying it twice must give the same result as applying it once, because data that crosses several boundaries gets normalized several times. Spotify learned this from an attack that its engineering blog described in 2013, some years after it happened.
In the post’s example, the victim’s account is named bigbird, and the attacker registers ᴮᴵᴳᴮᴵᴿᴰ, spelled in Unicode modifier letters. Spotify’s canonicalization turns that into BIGBIRD, which does not collide with bigbird. But when the attacker resets the new account’s password and uses the reset link, the code canonicalizes the name a second time, gets bigbird, and sets the password on the victim’s account. As the post puts it, “We were relying on nodeprep.prepare being idempotent, and it wasn’t.”
The flaw appeared when an upgrade from Python 2.4 to 2.5 changed a standard library module that the canonicalization code depended on. Spotify’s fix rejected any name that changed when canonicalized again. In JavaScript, one pass of lowercasing followed by Unicode compatibility normalization (NFKC) reproduces the flaw, and the reverse order is no cure:
import { ValidationError } from "./parse-order.ts";
// RFC 8264's order: lowercase,
// then normalize. One pass of it
// is not idempotent.
const prepare = (s: string) =>
s.toLowerCase().normalize("NFKC");
const once = prepare("ᴮᴵᴳᴮᴵᴿᴰ");
console.log(once, prepare(once));
// Prints: BIGBIRD bigbird
// The reverse order is no cure:
// J plus a combining caron turns
// into j plus the caron, and only
// then into the single ǰ.
const reverse = (s: string) =>
s.normalize("NFKC").toLowerCase();
const j = reverse("J\u030C");
console.log(j.length, reverse(j).length);
// Prints: 2 1
// Letters, marks, digits, _ . -
// but nothing default-ignorable
// (\p{DI}): those are invisible.
const ALLOWED =
/^(?!.*\p{DI})[\p{L}\p{M}\p{Nd}_.-]{3,32}$/u;
export function canonicalUsername(raw: string): string {
let name = prepare(raw);
// RFC 8264: repeat until the
// result is stable (at most
// three more passes).
for (let pass = 1; prepare(name) !== name; pass++) {
if (pass > 3) throw new ValidationError("username: unstable");
name = prepare(name);
}
// Validate the canonical form,
// not the raw input.
if (!ALLOWED.test(name)) {
throw new ValidationError("username: not allowed");
}
return name;
}
console.log(canonicalUsername("ᴮᴵᴳᴮᴵᴿᴰ"));
// Prints: bigbird
Checked against every code point in Node.js 24, lowercasing first gives 656 single characters a different result on the second pass; normalizing first gives none. Combining marks catch the reverse order out, though, as the J with a caron shows: the first pass lowercases the J, and only the second composes the j and the caron into ǰ. The two results look identical on screen and compare unequal, so two accounts can end up with names nobody can tell apart.
RFC 8264, the 2017 framework for preparing and comparing internationalized strings, requires case mapping before normalization and notes that this order is not always idempotent. It recommends applying the rules repeatedly until the result is stable and rejecting a string that has not stabilized after three more passes. The loop in canonicalUsername does that, in the RFC’s order. The allowlist then runs on the canonical result. Like the RFC’s rules for identifiers, it refuses default-ignorable code points, such as variation selectors, that could make two names look the same.
Some limits remain. Keep NFKC for identifiers: Unicode’s normalization annex warns that the compatibility forms “must not be blindly applied to arbitrary text.” Even for usernames, RFC 8265, the framework’s profile for them, is stricter: apart from fullwidth and halfwidth forms, it rejects compatibility characters such as ᴮ instead of folding them, and it normalizes with NFC. No normalization form maps look-alikes across scripts, either, so a Cyrillic а stays a Cyrillic а. Unicode Technical Standard #39 defines confusable detection for that case. Keep the name the user typed for display, store the canonical form under a unique index, and compare canonical forms only.
Sanitize at the Destination: Could This Data Be Weaponized?
A value can pass every check and still be dangerous in the wrong place. O'Brien, spelled with a plain apostrophe, is a valid surname, and it breaks any SQL statement that pastes it between single quotes. That is why sanitization belongs at the destination, where the data meets an interpreter, and not at the front door. OWASP’s SQL injection cheat sheet warns that “validated data is not necessarily safe to insert into SQL queries via string building.” The input validation sheet adds that “blocking apostrophes, for example, rejects legitimate names without making a database query safe.”
Each destination has its own technique:
- SQL: use parameterized queries. The statement is written first and each value is passed in separately, so the database “will always distinguish between code and data, regardless of what user input is supplied.” OWASP lists escaping last among its options, labeled “STRONGLY DISCOURAGED.”
- Processes: do not build command strings. Pass the program and its arguments as an array, with no shell in between; in Node.js,
execFile“does not spawn a shell by default.” Where a command supports it, put--before user-supplied arguments, so that a value starting with a hyphen cannot become an option. - HTML: start with your framework’s automatic output encoding, as OWASP’s cross-site scripting cheat sheet advises, and use a maintained sanitizer library when users may write HTML themselves.
Normalizing a value first changes none of this. As the input validation sheet puts it, “Normalization is not sanitization and does not replace output encoding.”
Progressive Hardening: A Different Check at Each Layer
Defense in depth is often pictured as a row of walls: a firewall, then a web application firewall, then the application. I think of boundary defense as progressive hardening instead. Each layer does more than add a wall: it rechecks the assumptions it depends on, and each check narrows what the next layer has to trust. Suppose a payment passes through four layers:
- The API gateway checks the request’s structure, authenticates the caller, and confirms that the caller may make payments at all.
- The payment service checks the amount against the currency’s precision rules, checks the balance, and applies the business rules for this account.
- The data layer relies on the database to enforce types, foreign keys, and constraints on every write, and checks the rows it reads back against what the service expects now.
- A final check runs atomically with the irreversible step, so that two concurrent payments cannot both pass it: do the debits equal the credits, and does this payment make sense given the account’s recent history?
The amount is checked several times, and not always the same way. Each layer checks what it depends on, plus what only it can see. The payment service checks the amount even when the gateway has checked the request, because a batch job or another internal caller may never pass through the gateway. The database’s part is the easiest one to forget:
CREATE TABLE payments (
id bigint PRIMARY KEY,
account_id bigint NOT NULL
REFERENCES accounts (id),
amount_cents bigint NOT NULL
CHECK (amount_cents > 0),
currency char(3) NOT NULL
CHECK (currency IN
('USD', 'EUR', 'GBP'))
);
Tested in PostgreSQL 18, this table rejects a zero or negative amount, an unknown or lowercase currency code, an account that does not exist, and a missing amount. It also accepts an amount of 12.5 and stores 13, because PostgreSQL converts an inserted value to the column’s type, and that conversion rounds. The column and its constraints do not check the same things as the application’s parser, which rejects 12.5 outright.
Constraints catch rows from every code path, including a backfill script that bypassed the service. They do not stop an attacker who can already run arbitrary SQL. Parameterized queries keep injected SQL out in the first place, and a database account with no more privileges than it needs limits what a successful attacker can do.
Typed transports help less than they seem to. A Protocol Buffers contract constrains how a field is represented, not whether its value makes sense. In proto3, a numeric field that a message omits reads as its default, and the language guide is explicit: “For numeric types, the default value is zero.” Unless the field is declared optional, the receiver cannot tell after parsing whether the sender set zero or set nothing. Declaring it optional restores that distinction, but not the check. A payment service that skips its own check because the contract is typed can end up processing a payment of zero.
When a Check Fails
Rejecting bad data is half of the job. The other half is deciding what rejection means, and the Cloudflare outage shows both ways to get it wrong.
Faced with the oversized feature file, the new version of the proxy treated it as a fatal error: it panicked with “called Result::unwrap() on an Err value,” and customer traffic got HTTP 5xx errors. The old version kept running but gave all traffic a bot score of zero, so customers with rules that blocked bots “would have seen large numbers of false positives.” Neither behavior ended the outage: Cloudflare stopped the new files and recovered by “manually inserting a known good file into the feature file distribution queue.”
The lesson generalizes: reject the input, not the process. For a configuration or feature file, that means validating each new version before it replaces the old one, and keeping the last good version when the new one fails:
import { ValidationError } from "./parse-order.ts";
type FeatureFile = Readonly<{ features: readonly string[] }>;
const MAX_CHARS = 64 * 1024;
const MAX_FEATURES = 200;
const NAME = /^[a-z0-9_]{1,64}$/;
function parseFeatureFile(text: string): FeatureFile {
if (text.length > MAX_CHARS) {
throw new ValidationError("file: too large");
}
let data: unknown;
try {
data = JSON.parse(text);
} catch {
// Parser messages can echo the
// input, so log our own text.
throw new ValidationError("file: not valid JSON");
}
if (typeof data !== "object" || data === null
|| !("features" in data) || !Array.isArray(data.features)) {
throw new ValidationError("features: missing");
}
const features: unknown[] = data.features;
if (features.length > MAX_FEATURES) {
throw new ValidationError(`features: over ${MAX_FEATURES}`);
}
if (!features.every((f): f is string =>
typeof f === "string" && NAME.test(f))) {
throw new ValidationError("features: bad name");
}
if (new Set(features).size !== features.length) {
throw new ValidationError("features: duplicates");
}
return Object.freeze({ features: Object.freeze([...features]) });
}
let active = parseFeatureFile('{"features":["tls_fp","ua_len"]}');
function onFilePublished(text: string): void {
try {
active = parseFeatureFile(text);
} catch (err) {
if (!(err instanceof ValidationError)) throw err;
// Reject the file, not the
// process: keep the last good
// file, and raise an alarm.
console.error("rejected:", err.message);
}
}
onFilePublished('{"features":["tls_fp","ua_len","tls_fp"]}');
onFilePublished('{"features": [');
console.log(active.features);
// Prints:
// rejected: features: duplicates
// rejected: file: not valid JSON
// [ 'tls_fp', 'ua_len' ]
This is a sketch, not Cloudflare’s code, but its checks would have refused a file with duplicate or excess features. The size check runs before parsing; the code that reads the file should enforce the same limit while reading. In production, the rejection would also raise an alarm, since a file that stays rejected is an incident of its own. And keeping the last good file is right only while that file is still safe to run. If a version that revokes access or retires a key fails validation, the operations that depend on the file should stop rather than run under the old rules.
Rejecting the input instead of the process works at other boundaries too. An HTTP request that fails validation gets a 400-level response that says what was wrong with the request and nothing about the system; stack traces and internal details go only to the log. A message that fails validation goes to a dead-letter queue instead of crashing the consumer, which after a restart would often receive the same message again. Either way, the bad input stops at the boundary, and the component keeps serving what it can.
OWASP added a category for this failure mode to its Top 10 for 2025. A10, Mishandling of Exceptional Conditions, is “a new category for 2025,” and the first cause its description lists is “missing, poor, or incomplete input validation.”
What It Costs and How to Pay Less
Checking at every boundary costs CPU time, latency, and code, and the objection that it will slow everything down deserves a real answer. Most of the answer lies in what “every boundary” means. It does not mean every function.
Parse once per boundary, and let the type carry the result inward. Alexis King made the case in a 2019 essay, “Parse, Don’t Validate.” Parsers, she writes, “allow discharging checks on input up-front, right on the boundary between a program and the outside world, and once those checks have been performed, they never need to be checked again!” Code that receives an Order from parseOrder does not repeat its checks. TypeScript types are structural, though: another module can build an Order by hand and skip every check. Where that matters, make the parser the only code that can create one.
The alternative, checks sprinkled through the business logic, has a name in language-theoretic security. A 2016 paper calls it shotgun parsing: validation code “mixed with and spread across processing code—throwing a cloud of checks at the input, and hoping, without any systematic justification, that one or another would catch all the ‘bad’ cases.” Boundary defense done well is the opposite: one parser per boundary, and trust inside it.
Schema libraries make that parser cheaper to write. With Zod, the same rules fit in a schema, and the static type is derived from it, so the two cannot drift apart:
import { z } from "zod";
const OrderSchema = z.object({
orderId: z.string().regex(/^ORD-\d{8}$/),
currency: z.enum(["USD", "EUR", "GBP"]),
amountCents: z.int().min(1).max(100_000_000),
lines: z.array(z.object({
sku: z.string().regex(/^[A-Z0-9-]{1,32}$/),
quantity: z.int().min(1).max(999),
})).min(1).max(100),
});
// The static type comes from the
// schema, so the two cannot drift.
type Order = z.infer<typeof OrderSchema>;
const result = OrderSchema.safeParse({
orderId: "ORD-20261014", currency: "USD", amountCents: 4999,
lines: [{ sku: "BOOK-1", quantity: 1 }], isAdmin: true,
});
if (!result.success) throw result.error;
const order: Order = result.data;
console.log(Object.keys(order));
// Prints: [ 'orderId', 'currency',
// 'amountCents', 'lines' ]
Zod’s z.object() strips unknown keys from its result by default, which gives you the new-object behavior for free; z.strictObject() rejects them instead. Unlike the hand-written parser, Zod returns ordinary mutable objects; .readonly() freezes them, one schema level at a time. The sample was checked with Zod 4.6.7, the latest release at the time of writing.
Two more ways to pay less need care:
- Remember results only for data that cannot change. Caching a validation result is tempting and risky. Saltzer and Schroeder’s 1975 paper on protection is about authority checks rather than data, but its warning carries over. Complete mediation, it says, “requires that proposals to gain performance by remembering the result of an authority check be examined skeptically.” A result keyed by a hash of immutable content and the version of the rules that checked it holds until the rules change. A result keyed by an ID whose data can change does not hold at all.
- Spend checks where the risk is. The closer data is to an untrusted source, and the more sensitive the operation, the more checking it deserves. Authentication, payments, and privilege changes justify the cost; a read-only, nonsensitive internal query may not. Measure before you relax anything.
The development cost shrinks too. Write reusable parsers for common values such as email addresses, currency amounts, and identifiers. Test each boundary with malformed, oversized, and missing fields, not just bad syntax. And keep a list of the places where validation was deferred during prototyping, tracked like any other technical debt.
When to Relax the Rule
Every principle has exceptions, and this one has three reasonable ones:
- One program, one compiler, one deployment. Inside a strictly typed codebase that is built and shipped as a unit, you can validate at the public edges and let the types carry the result inward. Remember what the types are, though. The TypeScript handbook is plain about it: “Type annotations never change the runtime behavior of your program.” Every
ascast, everyany, and everyJSON.parseis a place where the compiler takes your word for it. - Immutable pipelines. When data is validated on ingestion against everything the later stages rely on, and no later stage can change it, checking it again at every stage adds little.
- Administrative tools over validated stores. An internal tool that works on data the database already constrains can lean on those constraints.
Even then, assertions are worth keeping: they catch bugs, document assumptions, and make refactoring safer. And one kind of boundary never relaxes. Anything exposed to the outside world, or guarding authentication, payments, or privileges, validates every time.
Where this principle meets Jon Postel’s robustness principle, it deliberately reverses half of it. The TCP specification of 1980 told implementations to “be conservative in what you do, be liberal in what you accept from others.” On security-critical paths, boundary defense chooses strictness over tolerance. The reversal is less radical than it sounds. RFC 1122, which restated the principle in 1989, adds in the same section that “it is best to assume that the network is filled with malevolent entities that will send in packets designed to have the worst possible effect.”
In 2023 the Internet Architecture Board published RFC 9413, which argues that tolerating unexpected input lets a flaw become “entrenched as a de facto standard.” It warns that “hiding the consequences of protocol variations encourages the hiding of issues, which can conceal bugs and make them difficult to discover.” A strict consumer exposes a producer’s bug with the first malformed message; a tolerant one hides it until it matters.
A Checklist for Every Boundary
- Find the boundaries. Mark every interface where data changes hands, and give the fullest checks to data from code that was not built, tested, and deployed with yours, including stored data and configuration.
- Parse at each one. Bound the input; check type, range, format, and invariants; build a new object from the checked fields; and reject what fails rather than repairing it.
- Canonicalize, then validate. Make normalization idempotent, store the canonical form, and check paths after resolving them.
- Sanitize at the destination. Use parameters for SQL, argument arrays for processes, and your framework’s encoding for HTML.
- Derive privileges on the server. Roles and permissions come from the authenticated identity, never from the payload.
- Decide what failure does. Reject the input, not the process: keep the last good configuration while it is still safe, dead-letter the bad message, and raise an alarm.
- Relax only in the narrow cases. They are one program built and shipped as a unit, an immutable pipeline that validated on ingestion, and a tool over a constrained store. Keep assertions there, and never relax at the outside edge or around authentication, payments, and privileges.
Before the next line of code that reads from another service, a database, or a queue, ask the question this principle comes down to: am I trusting this data, and should I?
Further Reading
- Zero Trust Architecture (NIST Special Publication 800-207, August 2020)
- Input Validation Cheat Sheet (OWASP)
- External Technical Root Cause Analysis—Channel File 291 (CrowdStrike, 6 August 2024)
- The Protection of Information in Computer Systems (Jerome H. Saltzer and Michael D. Schroeder, 1975)
- RFC 9413: Maintaining Robust Protocols (Martin Thomson and David Schinazi, Internet Architecture Board, June 2023)