SecurityGENERALLANGUAGE-SPECIFICGENERAL

Trust Boundaries

A trust boundary is a line in your code, not a line on a network diagram: the place past which data is assumed clean, which is only true if one place made it so.

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 survives until the requirement changes.

The question

Where in the code does untrusted data become trusted, and what makes that a place rather than a habit?

The requirement

The payment provider will POST webhooks to us: payment succeeded, payment failed, refund issued. We update the order and email the customer.

The obvious build

Validate in the handler. Check the signature, check the fields are present, then call the domain service with the parsed body. It is one function, it is easy to read, and every framework tutorial shows it this way.

Why it breaks

A second entry point appears — a replay tool, an admin "resend this webhook" button, a test fixture, a queue consumer for the same event — and each one reconstructs the validation, or skips it because "it comes from us".

How it breaks as requirements change
  • A second entry point appears — a replay tool, an admin "resend this webhook" button, a test fixture, a queue consumer for the same event — and each one reconstructs the validation, or skips it because "it comes from us".
  • The domain service takes the raw parsed object, so it grows defensive checks of its own; now validity is asserted in two places and guaranteed in neither (Invariant Leaks).
  • The provider adds a field and the shape drifts. Because the boundary was a function and not a type, nothing in the codebase can tell you which fields the trusted side actually relies on.
  • The requirement changes to "also accept webhooks from a second provider", and the boundary has to be found by grepping for the signature check, because it was never a named thing.
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • The endpoint is public — anyone on the internet can POST to it, which is what makes it a boundary rather than an internal call.
  • The provider retries, so the same event arrives more than once and the handler cannot assume novelty (Idempotency by Design).
  • The payload shape is the provider's, changes when they version it, and is documented rather than guaranteed.
Invariants
  • No code past the boundary ever sees an unverified payload — verification is not optional and not conditional.
  • Whatever the trusted side receives has the shape it claims: if the type says OrderId, there is no path by which it holds an arbitrary string.
  • The boundary is crossed in exactly one direction, at exactly one place, per source.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • The boundary module owns authenticity (is this really from the provider), shape (does it parse), and translation (into our vocabulary, not theirs).
  • The domain owns what the event *means* — that a succeeded payment moves an order to paid — and must be unable to see the provider's field names at all (Anti-Corruption Layer).
  • Nobody past the boundary is responsible for validation, and that is the whole point: a responsibility shared is a responsibility unheld.
Boundaries
  • The line falls where the data changes owner, not where the network changes. A message off an internal queue whose producer is another team is untrusted in exactly the sense that matters.
  • It falls *before* translation, not after: verify the raw bytes, then parse, then translate. Verifying a re-serialised object is a signature check on something the provider never signed.
  • The same line is where authorization belongs for request-shaped traffic — the caller's identity is established once, at the crossing, and carried inward as a value (Designing for Security).

Where the line actually falls

The instinct is to draw trust boundaries on an infrastructure diagram: outside the VPC is untrusted, inside is trusted. That picture is not wrong, it is just at the wrong altitude to be useful when you are deciding where a function goes.

The version that changes code is narrower. Data has an owner; a boundary is where ownership changes. The internet owns a webhook body. A customer owns an uploaded CSV. Another team owns the message on the queue. A model owns its own output. Each of those is a crossing, and each needs the same three things in the same order: authenticate, parse, translate.

  • Authenticity is checked on the bytes as received — re-serialising first checks a signature over something the provider never signed.
  • Parsing is untrusted work and can itself fail badly, which is why it comes second and not first.
  • Translation is what stops the provider's vocabulary leaking inward; without it the boundary is a checkpoint with no customs.
  • The output type is the receipt. If the domain can only be called with PaymentEvent, "did we validate" stops being a question (Backend Engineering calls the technique parse, do not validate).
One crossing, three jobs, and the type that proves it happened
authenticnot authenticwell-formedmalformedour vocabulary, our typeProvider POST (untrusted bytes)Verify signature over raw bytesParse into RawWebhookTranslate to PaymentEventReject: one place, one policyOrder domain (assumes clean)
UserLLMAgentToolDataDecisionHumanGuardrail

A comment is not a boundary; a type is

LANGUAGE-SPECIFICThe branded-type trick shown is TypeScript's; Rust uses a private field, Java a sealed interface with a package-private constructor, Python nothing at all — there the same design is a module boundary plus a test asserting that only intake imports the schema, which is a weaker guarantee bought with discipline rather than the compiler.

Both versions below check the signature, and in a review both look fine. The difference shows up when someone adds the second caller — the replay tool, the retry job, the test fixture — because in the first version that caller *can* skip the boundary and in the second it cannot.

That is the whole design claim of this lesson: a trust boundary is only real if crossing it is the only way to obtain what the trusted side requires.

The same check, made bypassable and unbypassable
Validation as a step someone remembers
// webhooks/handler.ts
export async function handle(req: Request) {
  if (!verify(req.rawBody, req.headers['x-signature'])) return reject()
  const body = JSON.parse(req.rawBody)          // plain object
  await orders.applyPayment(body)               // takes `any`
}

// tools/replay.ts — six months later, written by someone
// who knows the payload is fine because we stored it
await orders.applyPayment(JSON.parse(stored))   // compiles, ships
Validation as the only constructor
// webhooks/intake.ts — the only file that can make one
export type PaymentEvent = {
  readonly _intake: unique symbol
  orderId: OrderId; kind: 'paid' | 'failed' | 'refunded'
}
export function intake(raw: Buffer, sig: string): PaymentEvent | Rejected {
  if (!verify(raw, sig)) return rejected('signature')
  const parsed = schema.safeParse(JSON.parse(raw.toString()))
  return parsed.success ? translate(parsed.data) : rejected('shape')
}

// orders/apply.ts
export async function applyPayment(e: PaymentEvent) { /* assumes clean */ }

// tools/replay.ts — must call intake(); there is no other way in

The first design distributes the obligation to every future caller, and the population of future callers is unbounded and unknown to you. The second concentrates it in one module and lets the type system enforce what a convention cannot: not "please validate" but "there is no unvalidated value of this type in existence".

What the boundary module is allowed to be

Boundaries attract work. They are the first place a request is seen, so deduplication lands there, then rate limiting, then a metric, then a small business rule because "we already have the order loaded". The result is a module with five unrelated reasons to change sitting on the most security-sensitive line in the codebase.

The discipline is to keep the boundary about the crossing. Everything below is legitimately its job; anything that would still be needed if the event arrived from inside the system is not.

responsibilitiesThe payment-provider webhook boundarywebhooks/intake
Knows
  • The provider's signing scheme and current signing keys.
  • The provider's payload shape, including the fields we deliberately ignore.
  • The mapping from their vocabulary (charge.succeeded) to ours (paid).
Does
  • Verifies authenticity on the raw bytes.
  • Parses and rejects anything that does not match the expected shape.
  • Produces a domain event type that cannot be constructed elsewhere.
  • Decides what rejection means: dropped with a metric, or dead-lettered for inspection.
Depends on
  • The signing key source (rotated, so injected rather than read at import time).
  • The domain's event vocabulary — one direction only.
Changes when — 3 distinct reasons
  • The provider versions their payload.
  • The signing scheme or key rotation policy changes.
  • We start caring about a field we previously ignored.

Three reasons to change, all about the provider — that is a coherent module. If a fourth appears that is about *orders* rather than about the provider, it belongs on the other side of the line; the give-away is a change request that would apply equally to an order paid at the till.

How to build it

Most important first.

  • Name the boundary. A module called webhooks/intake that nothing bypasses is a design artefact; a validate() call at the top of a handler is a habit.
  • Make the crossing produce a *different type*. RawWebhook goes in, PaymentEvent comes out, and there is no constructor for PaymentEvent outside the boundary — then "did we validate" is answered by the compiler rather than by reading (Making Illegal States Unrepresentable).
  • Verify authenticity on the exact bytes received, before any parsing, because parsing is itself untrusted work (Backend Engineering owns the signature mechanism; the design point here is the ordering).
  • Translate at the boundary. The trusted side should not know the provider's vocabulary, so a provider version bump is a change inside one module (Boundary Adapters).
  • Give the boundary one place to reject, and decide there what rejection means — dropped, dead-lettered, retried — rather than letting each failure mode pick (Error Boundaries).
  • Do the same for every source, and keep the list short. The number of trust boundaries is a number worth knowing; a codebase that cannot state it has none (Security Engineering calls the same count an attack surface).

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • Adding a second provider: one new boundary module, one new translation, zero changes to the domain. The cost is bounded because it is the boundary's job to absorb foreign shapes.
  • The provider changing their payload: one module, one test file, and the compiler tells you what stopped matching. Under the naive design this is a grep for field names, which is unbounded because a field name is a string.
  • Adding a new internal producer of the same event — the replay tool — is where the two designs diverge most: with a typed crossing it must go through the boundary or it cannot construct the type, so the cheap wrong thing is impossible rather than discouraged.
  • What is not cheaper: changing what "verified" means (adding an allow-list of source IPs, say) still touches every boundary module, because each one owns its own authenticity. Extracting that is a second decision, and doing it early usually produces the wrong shared shape.
What the recommended approach costs
  • A typed crossing costs a type, a constructor and a translation for every source, which on a small system with one webhook is more machinery than the problem deserves.
  • Translation means the trusted side cannot see fields the provider sends but we did not model, so a field you need later requires a change at the boundary rather than being already there. That friction is the feature and it is still friction.
  • One boundary per source duplicates signature-checking logic across modules. That duplication is deliberate — the sources genuinely change independently — but it looks like a DRY violation in review and has to be defended every time (DRY: Knowledge, Not Lines).

What can go wrong

Failure modes
  • The boundary is bypassed by an internal path, which is the single most common way this design fails — and it fails silently, because the internal path is written by someone who knows the data is fine.
  • Validation happens at the boundary and the *trusted* type is constructible anywhere, so a test helper or a migration script creates one from raw input and the guarantee evaporates.
  • The boundary becomes a god module: authenticity, parsing, translation, deduplication, rate limiting, business rules. It ends up with every reason to change (God Object).
  • Verification is moved after parsing during a refactor because the code reads more naturally that way, and nobody notices that the order was the security property.
Dependencies, and their direction
  • The boundary depends on the provider's format and on the domain's vocabulary; both dependencies point inward from it, and nothing points back out (Dependency Direction).
  • The domain depends on PaymentEvent, which is ours. If it ever imports the provider's SDK types, the boundary has failed even though the check still runs.
  • Adding a second provider adds a second boundary module and no change to the domain — that is the test of whether the direction was right.
Misreads
  • "The boundary is the network edge." It is wherever data changes owner. An internal queue fed by another team, a file uploaded by a customer, and an LLM's output are all crossings inside your own process (Where the Probabilistic System Ends).
  • "Validation and authorization are the same boundary." They coincide for HTTP requests and diverge everywhere else: a webhook is authentic but has no user, and a background job has a principal but no untrusted input.
  • "Once we validate, we are safe." Validated means well-formed and authentic. It does not mean the content is honest — a genuine webhook can still say something the domain must refuse (Backend Engineering separates transport validation from business validation for exactly this reason).
  • "Parsing into a type is a functional-programming affectation." The mechanism is language-dependent; the design claim is not. Any language can make the trusted value constructible in exactly one place, and the ones that cannot enforce it need the test instead.
Smells this explains
  • primitive-obsession
  • shotgun-surgery

Testing it, and how it ages

What to test, and at which boundary
  • Test at the boundary with hostile input: wrong signature, replayed body, truncated JSON, unexpected extra fields, correct signature over a different body. This is the one place where adversarial test data belongs.
  • Assert the ordering explicitly — a test that a payload with a valid shape and an invalid signature is rejected *before* any parsing side effect happens.
  • Test the domain with PaymentEvent values directly and no HTTP at all; if that is awkward, the boundary is not where you think it is (Testing as Design Feedback).
  • A structural test is legitimate here: assert that the provider's SDK types are imported by exactly one module. It is a cheap guard against the failure that matters most (What to Automate Out of Review).
How this design ages
  • Boundaries accumulate sources. The design ages well while each source has its own module and badly the moment someone factors them into a shared validateWebhook() that takes a provider name (Premature Abstraction).
  • The trusted type tends to grow toward the provider's shape under deadline pressure — a passthrough field here, a raw payload attached "for debugging" — and that is how translation quietly stops happening.
  • If the system later grows real internal service boundaries, the same question re-opens: is another team's service trusted? Usually the honest answer is "less than you have been treating it".

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • GENERALThat data crossing an ownership line must be verified once, at a findable place, is independent of language and stack; only whether the compiler can enforce it varies.
  • LANGUAGE-SPECIFICWith sum types and private constructors — Rust, Haskell, TypeScript with branded types, Java with sealed hierarchies — "unvalidated data cannot reach the domain" is a compile error. In Python or Ruby the same design needs a naming convention plus a test that asserts the import graph, so the guarantee is weaker and the discipline has to be real.
  • GENERALThe deliberate collision with Security Engineering's lesson of the same name is the point: it teaches the attacker's view of what a boundary protects, we teach where the line goes in the code, and neither is sufficient alone.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

API Designwebhooks
Domains that do not exist yet
  • Programming Languages & Runtime Internals — whether "unvalidated data cannot reach the domain" is a compile error or a code-review convention is a property of the type system, and the machinery that makes a constructor genuinely private differs sharply between languages.