Trust Boundaries
A trust boundary is any point where data or control crosses from something you do not control into something you do — and every one of them is a place where an assumption must be re-validated rather than inherited.
Frame the problem
Security starts with a concrete asset, attacker capability and trust crossing.
A boundary is where you stop assuming
Inside one process, code trusts itself: a function that receives an Order object assumes the fields are the right types and the invariants hold, because a constructor enforced them. That assumption is cheap and correct. The bugs happen where the assumption *crosses* into a place it was never established — a JSON body from a browser, a message from a queue, a row from a table another team writes to, a response from an API you do not own.
The rule is simple to state and hard to hold: inherited trust is the vulnerability. When code on the inside treats a value as validated because "the gateway checks that", and the gateway believes "the service validates it", nobody validates it. Both statements are defensible in isolation, which is why boundary bugs survive review.
A boundary is therefore identified by a question, not by a network diagram: *if the thing on the other side were hostile, what would I have to check?* If the answer is "nothing, it is internal", you have found a boundary that is being crossed on faith.
The boundaries teams forget
The browser → API boundary is well known. The ones that produce incidents are the ones that do not look like boundaries because both sides are "ours": a queue consumer that trusts message contents because your own producer wrote them (until an attacker finds a way to enqueue), a database column populated by an admin tool and later rendered as HTML, a cache whose key is partly user-controlled, a log line that ends up in a dashboard that renders markup.
Two modern ones matter especially. CI/CD is a boundary: code from a fork, a dependency's install script, and a build container all run with the permissions of the pipeline, on the inside of the network — see CI/CD Security. And AI agents create a boundary that did not exist before: content retrieved from a document or a web page enters a context window where it sits next to your instructions, and unless the system treats it as data, the model may act on it. That is the whole of Prompt Injection and Tool Output Is Untrusted.
A useful habit when reading an architecture diagram: mark every arrow with the answer to "who can cause this arrow to carry a value they chose?" Arrows where the answer is "an outsider" are the boundaries; arrows where the answer is "an outsider, two hops upstream" are the ones that get missed.
Validating at the boundary, deciding inside it
Two different jobs happen at a crossing and conflating them is the most common design error. Validation is structural and semantic: is this a well-formed request, is quantity an integer in range, is url a URL with an allowed scheme? It belongs at the edge of the component, as early as possible, and it should reject rather than sanitise, because sanitising guesses at intent.
Authorization is contextual: may *this* principal perform *this* action on *this* resource right now? It cannot be done at the gateway, because the gateway does not know that invoice 101 belongs to tenant B. It must happen where the resource is loaded — which is why the fix for Broken Access Control (IDOR / BOLA) is never "add a check at the edge".
Parse, then validate, then authorize, then act. Each step narrows what the next one has to worry about, and each one is testable in isolation. When a security review asks "where is this validated?" and the answer is a layer diagram rather than a line of code, the boundary is decorative.
1// gateway already authenticated the request, so the service trusts the header2app.get('/invoices/:id', async (req, res) => {3 const tenantId = req.header('x-tenant-id') // set by the gateway… and forgeable4 const invoice = await db.invoice(req.params.id) // no ownership check at all5 res.json(invoice)6})1app.get('/invoices/:id', requireAuth, async (req, res) => {2 const id = InvoiceId.parse(req.params.id) // 1. parse: reject malformed input3 const invoice = await db.invoice(id) // 2. load the resource4 if (!invoice) return res.sendStatus(404)5 if (invoice.tenantId !== req.principal.tenantId) // 3. authorize with full context6 return res.sendStatus(404) // 404, not 403: do not confirm existence7 res.json(invoice)8})The vulnerable version reads identity from a header the gateway *happens* to set — a value the service cannot distinguish from one a client supplied, unless the gateway strips it, which nobody verified. The hardened version derives the principal from the authenticated session and makes the ownership decision after loading the resource, which is the only point where tenant, actor and action are all known.
Key points
- A trust boundary is defined by a question — "if the other side were hostile, what would I check?" — not by a network topology.
- Inherited trust is the bug: "the gateway checks it" plus "the service checks it" frequently equals nobody checking it.
- Validation (structural, at the edge) and authorization (contextual, at the resource) are different jobs and cannot substitute for each other.
- Queues, database columns, CI pipelines and retrieved documents are boundaries that do not look like boundaries.
- A header the client could set is not an identity, no matter which proxy is supposed to overwrite it.
Boundary control exercise
This lesson uses the shared boundary-control exercise.
Boundary control exercise
Follow the attack
Safe conceptual simulation: capability → missing control → crossed boundary → asset impact.
- 1Attacker → find an inherited assumption: a value that one component validates and another consumes without checking.
- 2Inherited assumption → forge the value: set the header, craft the queue message, poison the cached entry, plant the instruction in a document.
- 3Forged value → cross the boundary: the downstream component treats it as established fact because "it came from inside".
- 4Inside → act with the trust of the inner zone: read another tenant, call an internal-only endpoint, use a privileged credential.
- A single forged field can promote an outsider to the privileges of the innermost zone that trusts it.
- Because the trust was inherited, the request looks legitimate in logs — the identity is wrong, not the shape.
- Blast radius equals the scope of the credentials held by the zone the attacker reaches, not the zone they started in.
Defend, detect, recover
One prevention is a single point of security failure. Layer it and make failure observable.
- • Strip and re-set every trusted header at the edge, so no client-supplied value can impersonate an infrastructure claim.
- • Propagate the authenticated principal end to end (a signed token carrying the user, audience and expiry), so every hop can authorize rather than assume.
- • Parse into typed values at every entry point — HTTP handler, queue consumer, webhook receiver, tool result handler — and reject what does not fit.
- • Authenticate workload-to-workload calls (mTLS or signed service tokens) so network position is not an identity.
- • Alert when a request arrives at an internal service carrying a principal header without a valid signature.
- • Log the *source zone* alongside the identity so a call that skipped the gateway is visible in the data rather than only in theory.
- • Compare gateway request counts with backend request counts; a persistent gap means something is reaching the backend directly.
- • Treat any bypass as identity compromise: revoke sessions and tokens that could have been minted through the bypassed path.
- • Close the network path first (security group, service mesh policy), then fix the trust logic — the network fix is faster and buys time.
- • Re-derive what the forged identity could reach and audit those resources rather than only the endpoint that was abused.
- • Boundaries multiply with every new integration, and the newest one is always the least reviewed.
- • A correct boundary can be undone by a configuration change — a proxy rule, a mesh policy, a firewall exception made during an incident.
- • Internal tooling (admin panels, support consoles, debug endpoints) routinely sits inside all boundaries with the widest privileges.