<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Service Mesh on Matt Goodrich</title><link>https://mattgoodrich.com/tags/service-mesh/</link><description>Recent content in Service Mesh on Matt Goodrich</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><lastBuildDate>Thu, 09 Jul 2026 00:01:00 -0700</lastBuildDate><atom:link href="https://mattgoodrich.com/tags/service-mesh/index.xml" rel="self" type="application/rss+xml"/><item><title>Whose Request Is This, Three Hops In?</title><link>https://mattgoodrich.com/posts/whose-request-is-this-three-hops-in/</link><pubDate>Thu, 09 Jul 2026 00:01:00 -0700</pubDate><guid>https://mattgoodrich.com/posts/whose-request-is-this-three-hops-in/</guid><description>&lt;img src="https://mattgoodrich.com/posts/whose-request-is-this-three-hops-in/header.png" alt="Featured image of post Whose Request Is This, Three Hops In?" />&lt;p>A request comes into your product carrying a real, authenticated user. By the third internal service it touches, that user is gone. The service writing to the database does not know who made the request or which tenant they belong to. It knows only that the call came from another service inside the cluster, and inside the cluster, services trust each other. The user authenticated once, at the front door, and their identity quietly fell off the request somewhere in the hallway.&lt;/p>
&lt;h2 id="the-front-door-checks-the-user-the-hallway-doesnt">The Front Door Checks the User. The Hallway Doesn&amp;rsquo;t.
&lt;/h2>&lt;p>Most products authenticate the user hard at the edge. The API gateway validates the session or the JWT, confirms who they are, and decides whether they may call this endpoint at all. From there, handling the request fans out into internal calls: the API service calls the orders service, which calls billing, which calls the ledger. The question &amp;ldquo;is this user allowed to do this&amp;rdquo; was answered once, at the edge, for the entry point. It rarely gets asked again on the way down.&lt;/p>
&lt;p>The fan-out is easy to underestimate. Eight or nine years ago, on a consulting engagement, we traced a single inbound API call and counted 22 service calls behind it. The write-up was mostly about latency and instability, because 22 hops is 22 timeouts, retries, and partial failures, but the part that stayed with me was that somewhere in those calls, the user was gone. Nobody had decided to drop identity. It just was not any one service&amp;rsquo;s job to carry it.&lt;/p>
&lt;p>In Kubernetes this is the default shape, not a mistake someone made. A pod calls another pod&amp;rsquo;s service over the network, and there is no user in that call unless you deliberately put one there. The orders pod has a service account, the billing pod has a service account, and the cluster lets them talk. Whoever the human was that started the request is not part of the picture by the time billing is doing its work. The internal calls run on implicit trust: they came from inside, so they are allowed.&lt;/p>
&lt;h2 id="channel-identity-vs-caller-identity">Channel Identity vs Caller Identity
&lt;/h2>&lt;p>Two different identities are in play on any internal call, and teams routinely secure one and forget the other.&lt;/p>
&lt;p>&lt;strong>Channel identity&lt;/strong> is which service is talking to which. mTLS answers it: this connection is between the orders service and the billing service, both proven by certificates, encrypted end to end. A service mesh automates that across the whole cluster, so every pair of services gets a proven, encrypted channel with no application code involved.&lt;/p>
&lt;p>&lt;strong>Caller identity&lt;/strong> is who the request is ultimately for. The human or external client that authenticated at the front door, on whose behalf every downstream call is being made. mTLS says nothing about this. The billing service can know with certainty that the orders service called it and have no idea which of ten thousand customers the call is about.&lt;/p>
&lt;p>The mesh secures the channel. The caller is a separate fact, and it has to be carried deliberately, because nothing in the transport carries it for you.&lt;/p>
&lt;h2 id="what-a-service-mesh-actually-gives-you">What a Service Mesh Actually Gives You
&lt;/h2>&lt;p>Be precise about what the mesh does, because it does real things and they are easy to mistake for the whole job. &lt;a class="link" href="https://istio.io/" target="_blank" rel="noopener"
>Istio&lt;/a>, &lt;a class="link" href="https://linkerd.io/" target="_blank" rel="noopener"
>Linkerd&lt;/a>, and the like give you three. mTLS between every pair of services, so traffic is encrypted and each peer proves its workload identity, usually a &lt;a class="link" href="https://spiffe.io/" target="_blank" rel="noopener"
>SPIFFE&lt;/a> identity baked into the certificate. Authorization policies on that workload identity, so you can say the orders service may call billing while the reporting service may not. And reduced routes, so a service can reach only the specific services it declares, which shrinks the blast radius of a compromised pod. &lt;a class="link" href="https://cilium.io/" target="_blank" rel="noopener"
>Cilium&lt;/a> does the same job at the network layer with eBPF instead of sidecars, and &lt;a class="link" href="https://www.consul.io/" target="_blank" rel="noopener"
>Consul&lt;/a> covers the same ground outside Kubernetes; the pattern holds whichever one you run.&lt;/p>
&lt;p>Every one of those is about the workload. Which service, reaching which service, over an encrypted channel. That is the channel-identity half done well, and it is worth having. It is also silent on the caller. An attacker who lands in the orders pod, or a bug in the orders code, calls billing as the orders service, which is exactly what the mesh exists to allow. The mesh will faithfully encrypt and authorize a request to move money for the wrong customer.&lt;/p>
&lt;h2 id="the-dropped-passenger">The Dropped Passenger
&lt;/h2>&lt;p>So where does the user go? One of two places, and both are common.&lt;/p>
&lt;p>The first is that the edge drops the user entirely. The gateway authenticates them, then the first internal service makes its downstream calls using its own service identity and a set of parameters. The user&amp;rsquo;s token never leaves the edge. Everything downstream runs on ambient authority: the orders service is powerful, it can write any order, and it decides what to do from the IDs passed in the call. This is the confused deputy in its natural habitat. The downstream service holds the authority, the caller supplies the target, and nobody checks that the caller was entitled to that target.&lt;/p>
&lt;p>The second is subtler and more dangerous. The user&amp;rsquo;s token is passed downstream, sitting in a header, technically present at every hop, and no service downstream actually validates it or makes a decision with it. The ledger service sees a JWT, does not check it, trusts that someone upstream already did, and acts on the account ID in the request body. Carried but not enforced is worse than dropped, because it looks like the work is done. There is a user identity right there in the request. It just is not load-bearing.&lt;/p>
&lt;p>&lt;img src="https://mattgoodrich.com/posts/whose-request-is-this-three-hops-in/diagram-dropped-caller.png"
width="3168"
height="1488"
srcset="https://mattgoodrich.com/posts/whose-request-is-this-three-hops-in/diagram-dropped-caller_hu6286681367924581764.png 480w, https://mattgoodrich.com/posts/whose-request-is-this-three-hops-in/diagram-dropped-caller_hu17038626201273451606.png 1024w"
loading="lazy"
alt="The Dropped Caller: a User in Tenant A Sends a Request With a Token, the API Gateway Validates It and Is the Only Hop That Knows the Caller, Then Each Internal Call From Gateway to Orders to Billing to Ledger Proves the Channel With mTLS While the Caller Is Dropped, Until the Ledger Writes an Entry Using a tenant_id From the Request Body With No User Check Because the Call Came From Inside"
class="gallery-image"
data-flex-grow="212"
data-flex-basis="510px"
>&lt;/p>
&lt;h2 id="logical-segregation-is-where-this-bites">Logical Segregation Is Where This Bites
&lt;/h2>&lt;p>This stays theoretical until multiple tenants share the same services, which is most SaaS. Multi-tenant products almost always run on logical segregation: one set of services, one or a few databases, and the boundary between tenant A and tenant B enforced in code, by a &lt;code>tenant_id&lt;/code> on every row and a &lt;code>WHERE tenant_id = ?&lt;/code> on every query. There is no physical wall. The isolation is an invariant the application has to maintain on every single data access.&lt;/p>
&lt;p>Now combine that with a call graph that does not carry the caller. A downstream service that trusts its caller and acts on the IDs it is handed cannot enforce the tenant boundary, because it does not know whose request this is. If the tenant scoping is derived from a value the caller controls instead of from a verified user identity, then a request originating in tenant A that reaches a service willing to act on &lt;code>tenant_id = B&lt;/code> has crossed the boundary. The bug might be an &lt;a class="link" href="https://mattgoodrich.com/posts/the-gateway-cant-see-the-object/" >object reference the gateway never saw&lt;/a>, an internal endpoint that takes an account ID and trusts it, a cache keyed without the tenant, a queue message that lost its tenant context. Each is a tenant-isolation failure, and each lives below the front door where the user check happened, in the part of the system that assumed everything internal was safe.&lt;/p>
&lt;p>This is a whole category: every internal call that touches tenant-scoped data and does not re-derive the tenant from a verified caller is a candidate. In a logically-segregated system, that is most of them.&lt;/p>
&lt;h2 id="carry-the-caller-then-enforce-it">Carry the Caller, Then Enforce It
&lt;/h2>&lt;p>The fix is two moves, and you need both. Carry the caller&amp;rsquo;s identity through the call graph, and make a decision with it at every hop that touches user- or tenant-scoped data.&lt;/p>
&lt;p>Carrying it: the user identity established at the edge becomes a credential that travels. &lt;a class="link" href="https://datatracker.ietf.org/doc/html/rfc8693" target="_blank" rel="noopener"
>OAuth token exchange&lt;/a> is the baseline mechanism. The edge exchanges the user&amp;rsquo;s token for a downstream token that still asserts the user, scoped down to what the next call needs, the on-behalf-of pattern. The newer and more purpose-built answer is &lt;a class="link" href="https://datatracker.ietf.org/doc/draft-ietf-oauth-transaction-tokens/" target="_blank" rel="noopener"
>Transaction Tokens&lt;/a>, an IETF OAuth working group draft built for exactly this. The edge mints an immutable token asserting the original caller and the request context, and that token is carried and verified through the whole internal call chain, so the ledger service three hops in can still prove who the original user was. Either way, the call now carries two identities: the workload identity from mTLS for the channel, and the caller identity in the token for the user.&lt;/p>
&lt;p>Enforcing it: carrying the token does nothing until a service makes a decision with it. This is the &lt;a class="link" href="https://mattgoodrich.com/posts/the-gateway-cant-see-the-object/" >object-level authorization&lt;/a> problem repeated at every hop, and the same tools answer it. The mesh can do part of the job. Istio &lt;a class="link" href="https://istio.io/latest/docs/reference/config/security/request_authentication/" target="_blank" rel="noopener"
>RequestAuthentication&lt;/a> validates the user JWT, and &lt;a class="link" href="https://istio.io/latest/docs/reference/config/security/authorization-policy/" target="_blank" rel="noopener"
>AuthorizationPolicy&lt;/a> can allow or deny on its claims, so you enforce user-aware rules at the mesh layer instead of only service-identity rules. For real object- and tenant-level decisions, the service asks a policy engine, &lt;a class="link" href="https://openpolicyagent.org/" target="_blank" rel="noopener"
>OPA&lt;/a>, &lt;a class="link" href="https://openfga.dev/" target="_blank" rel="noopener"
>OpenFGA&lt;/a>, or &lt;a class="link" href="https://cedarpolicy.com/" target="_blank" rel="noopener"
>Cedar&lt;/a>, the same way the &lt;a class="link" href="https://mattgoodrich.com/posts/your-authorization-model-is-never-done/" >authorization model&lt;/a> does at the edge, and the decision is made against the verified caller.&lt;/p>
&lt;p>The one rule that closes the tenant hole: derive the tenant from the verified caller identity, never from a value the caller passes in. The &lt;code>tenant_id&lt;/code> in a verified transaction token is trustworthy. The &lt;code>tenant_id&lt;/code> in the request body is a suggestion.&lt;/p>
&lt;p>&lt;img src="https://mattgoodrich.com/posts/whose-request-is-this-three-hops-in/diagram-carry-enforce.png"
width="3168"
height="1562"
srcset="https://mattgoodrich.com/posts/whose-request-is-this-three-hops-in/diagram-carry-enforce_hu80426332477147632.png 480w, https://mattgoodrich.com/posts/whose-request-is-this-three-hops-in/diagram-carry-enforce_hu10466346634366320402.png 1024w"
loading="lazy"
alt="Carry and Enforce the Caller: the API Gateway Validates the Users Token, Exchanges It via RFC 8693 at a Token Service for an Immutable Transaction Token Asserting the Caller, Tenant, and Context, and Every Downstream Hop From Orders to Billing to Ledger Verifies That Token and Authorizes the Caller, So mTLS Proves the Channel While the Token Proves the Caller at Every Hop, and the tenant_id Comes From the Verified Token, Never From the Request Body"
class="gallery-image"
data-flex-grow="202"
data-flex-basis="486px"
>&lt;/p>
&lt;h2 id="not-every-call-has-a-caller">Not Every Call Has a Caller
&lt;/h2>&lt;p>Here is where the model argues with itself. Not every internal call is made on behalf of a user, and forcing a user identity onto the ones that are not is both wrong and impossible. The nightly batch job that recomputes balances has no user. The reconciliation process, the cron that expires sessions, the queue worker draining events, the service warming a cache: these are system-initiated, and for them the &lt;a class="link" href="https://mattgoodrich.com/posts/when-you-can-use-workload-identity/" >workload identity is the right authority&lt;/a>. Demanding a caller token there is a category error, and teams that try it end up minting fake &amp;ldquo;system user&amp;rdquo; tokens that are worse than honest workload identity.&lt;/p>
&lt;p>So the real skill is sorting the call graph. Which calls are user-initiated, where carrying and enforcing the caller is mandatory, and which are system-initiated, where the workload identity is the whole answer. The expensive mistake is treating them the same in either direction: putting user-aware authZ on a batch job, or letting a user-initiated call run on pure workload trust.&lt;/p>
&lt;p>There is a real cost even where it belongs. Token exchange and per-hop validation add latency and moving parts, and every service that enforces caller authZ needs the policy and the data to make the decision. You will not do this on every call, and you should not. Do it where the call touches user- or tenant-scoped data, which is where the isolation guarantee actually lives, and leave the rest on workload identity.&lt;/p>
&lt;h2 id="dont-ask-developers-to-remember">Don&amp;rsquo;t Ask Developers to Remember
&lt;/h2>&lt;p>The weak spot in all of this is memory. A control that exists only when a developer remembers to add it will be missing from some fraction of endpoints, and in a multi-tenant system that fraction is your tenant-isolation bug rate. The sorting from the last section, this call has a caller and this one does not, has to live somewhere more durable than convention. There are four places to put it.&lt;/p>
&lt;p>&lt;strong>Declare it on the route, and fail closed.&lt;/strong> I have seen systems do this with an attribute or decorator per endpoint: &lt;code>[UserInitiated]&lt;/code> on the routes that must enforce a caller, &lt;code>[SystemOnly]&lt;/code> on the ones that run on workload identity. The fail-closed default is the control, and the annotation is its record. The framework refuses to serve a route that declares neither, so forgetting shows up as a failed startup or a failed CI run instead of a silent hole. The default matters too: undeclared must never mean system. If anything gets to be implicit, it is &amp;ldquo;user-initiated, caller required,&amp;rdquo; because that failure mode is a 401, not a cross-tenant write. The residual risk is the wrong annotation, which is one code review away. Better than the check being absent, but still a per-endpoint human decision.&lt;/p>
&lt;p>&lt;strong>Let one endpoint serve both, carefully.&lt;/strong> Some endpoints genuinely take both kinds of traffic: a user triggers a recalculation from the UI, and the nightly batch triggers the same recalculation for every account. The honest version branches on the credential. A transaction token present means caller authorization runs. Workload identity alone means the system policy runs, and the system policy must be narrower, not wider: an enumerated set of operations, not &amp;ldquo;everything, since there is no user to check.&amp;rdquo; The failure mode to design against is &amp;ldquo;no caller&amp;rdquo; quietly becoming &amp;ldquo;no check.&amp;rdquo; Make system a first-class principal with its own policy, never an absence. If the branch gets complicated, that is the signal to split the endpoint, which is the network-path option in miniature.&lt;/p>
&lt;p>&lt;strong>Push it into the service chassis.&lt;/strong> If every service is built on a shared platform layer, the chassis, then the chassis validates the transaction token before any handler runs and injects a verified caller context into the request. The handler does not verify anything, and more important, it cannot get a caller or a tenant any other way, because the only tenant accessor reads from the verified context. Pair it with the data layer and the guarantee gets teeth: &lt;a class="link" href="https://www.postgresql.org/docs/current/ddl-rowsecurity.html" target="_blank" rel="noopener"
>Postgres row-level security&lt;/a> keyed to a session tenant that only the chassis sets, so even a handler that forgot everything cannot read another tenant&amp;rsquo;s rows. The mesh can take the first slice of this, RequestAuthentication rejecting invalid tokens before they ever reach the app. This is the best return on effort of the four: the check is written once, by the platform team, reviewed hard, and every service inherits it. The precondition is real, though. A chassis has to exist, and the org has to hold the line that services are built on it.&lt;/p>
&lt;p>&lt;img src="https://mattgoodrich.com/posts/whose-request-is-this-three-hops-in/diagram-chassis-enforcement.png"
width="1438"
height="2880"
srcset="https://mattgoodrich.com/posts/whose-request-is-this-three-hops-in/diagram-chassis-enforcement_hu12725820250591999903.png 480w, https://mattgoodrich.com/posts/whose-request-is-this-three-hops-in/diagram-chassis-enforcement_hu1282170754520918356.png 1024w"
loading="lazy"
alt="Chassis Enforcement: an Incoming Internal Call With a Transaction Token Passes Through Platform-Owned Layers, the Mesh Sidecar Rejects Invalid Tokens Before the App Sees Them and the Chassis Middleware Returns 401 Before Any Handler Runs if the Token Is Missing or Unauthorized, Then Injects a Verified Caller Context, So the Developer-Owned Handler Contains Business Logic Only and Reads Caller and Tenant From the Verified Context, Backstopped by Platform-Owned Postgres Row-Level Security Keyed to a Session Tenant Only the Chassis Sets"
class="gallery-image"
data-flex-grow="49"
data-flex-basis="119px"
>&lt;/p>
&lt;p>&lt;strong>Separate the network paths.&lt;/strong> The strongest version makes the user/system sort physical instead of conventional. User-initiated traffic reaches a service on one listener, where the chassis demands and verifies a transaction token, no exceptions. System traffic arrives on a different listener, or a different service entirely, with workload-only authorization and a deliberately smaller API surface. Mesh policy pins the topology: only the gateway&amp;rsquo;s path may reach the user listener, only the batch and cron identities may reach the system listener. Now a batch job physically cannot call the user surface without a caller token, and a user request cannot wander onto the path that never checks one. The cost is real, two surfaces to build, document, and keep from drifting, and it forces the dual-mode question early. It is the right spend at the boundary where the money moves, and overkill applied everywhere.&lt;/p>
&lt;p>&lt;img src="https://mattgoodrich.com/posts/whose-request-is-this-three-hops-in/diagram-separate-paths.png"
width="2120"
height="1196"
srcset="https://mattgoodrich.com/posts/whose-request-is-this-three-hops-in/diagram-separate-paths_hu12126783947931537609.png 480w, https://mattgoodrich.com/posts/whose-request-is-this-three-hops-in/diagram-separate-paths_hu8881446819617519472.png 1024w"
loading="lazy"
alt="Separate Network Paths: the API Gateway Carries User-Initiated Traffic With a Transaction Token Minted at the Edge Over mTLS to the Services User Listener, Which Requires the Token With No Exceptions and Serves the Full API With the Caller Enforced Per Hop, While Batch, Cron, and Queue Workers With Workload Identity Only Reach a Separate System Listener Over mTLS Serving an Enumerated System Surface, and Mesh Policy Pins the Topology So Only the Gateways Path May Reach the User Listener and Only Batch and Cron Identities May Reach the System Listener"
class="gallery-image"
data-flex-grow="177"
data-flex-basis="425px"
>&lt;/p>
&lt;p>None of this requires exotic tooling. The declaration and chassis options map onto things the mainstream stacks already ship, either as language primitives or as boring, well-worn libraries:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Stack&lt;/th>
&lt;th>Declare on the route&lt;/th>
&lt;th>Chassis-level enforcement&lt;/th>
&lt;th>Carrying the caller in-process&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Java / Spring&lt;/td>
&lt;td>&lt;a class="link" href="https://docs.spring.io/spring-security/reference/servlet/authorization/method-security.html" target="_blank" rel="noopener"
>&lt;code>@PreAuthorize&lt;/code>&lt;/a> method security&lt;/td>
&lt;td>&lt;a class="link" href="https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/index.html" target="_blank" rel="noopener"
>OAuth2 resource server&lt;/a> validates the JWT before any controller runs&lt;/td>
&lt;td>&lt;code>SecurityContextHolder&lt;/code>; &lt;a class="link" href="https://microprofile.io/specifications/microprofile-jwt-auth/" target="_blank" rel="noopener"
>MicroProfile JWT&lt;/a> on Quarkus and Liberty was designed for cross-service propagation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>.NET&lt;/td>
&lt;td>&lt;code>[Authorize(Policy = ...)]&lt;/code> attributes&lt;/td>
&lt;td>JWT bearer middleware plus a &lt;a class="link" href="https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.authorization.authorizationoptions.fallbackpolicy" target="_blank" rel="noopener"
>&lt;code>FallbackPolicy&lt;/code>&lt;/a>&lt;/td>
&lt;td>&lt;code>HttpContext.User&lt;/code>; &lt;a class="link" href="https://learn.microsoft.com/en-us/entra/msal/dotnet/microsoft-identity-web/" target="_blank" rel="noopener"
>Microsoft.Identity.Web&lt;/a> handles on-behalf-of exchange&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Node / TypeScript&lt;/td>
&lt;td>&lt;a class="link" href="https://docs.nestjs.com/guards" target="_blank" rel="noopener"
>NestJS guards&lt;/a> with custom decorators&lt;/td>
&lt;td>a global &lt;code>APP_GUARD&lt;/code>, or &lt;code>express-jwt&lt;/code> / &lt;code>@fastify/jwt&lt;/code> mounted at the app level&lt;/td>
&lt;td>&lt;a class="link" href="https://nodejs.org/api/async_context.html" target="_blank" rel="noopener"
>&lt;code>AsyncLocalStorage&lt;/code>&lt;/a>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Python&lt;/td>
&lt;td>&lt;a class="link" href="https://fastapi.tiangolo.com/tutorial/security/" target="_blank" rel="noopener"
>FastAPI dependencies&lt;/a> per route; DRF permission classes&lt;/td>
&lt;td>FastAPI app-level dependencies; DRF &lt;code>DEFAULT_PERMISSION_CLASSES&lt;/code>&lt;/td>
&lt;td>&lt;a class="link" href="https://docs.python.org/3/library/contextvars.html" target="_blank" rel="noopener"
>&lt;code>contextvars&lt;/code>&lt;/a>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Go&lt;/td>
&lt;td>no annotations in the language; middleware on a router group is the declaration&lt;/td>
&lt;td>&lt;a class="link" href="https://github.com/coreos/go-oidc" target="_blank" rel="noopener"
>&lt;code>go-oidc&lt;/code>&lt;/a> verification in chi, echo, or gin middleware; gRPC interceptors&lt;/td>
&lt;td>&lt;a class="link" href="https://pkg.go.dev/context" target="_blank" rel="noopener"
>&lt;code>context.Context&lt;/code>&lt;/a>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Three of those stacks ship the fail-closed switch as a one-liner. ASP.NET&amp;rsquo;s &lt;code>FallbackPolicy&lt;/code> applies to every endpoint that declared nothing, DRF&amp;rsquo;s &lt;code>DEFAULT_PERMISSION_CLASSES&lt;/code> does the same for Django, and a NestJS global guard runs on every route unless one explicitly opts out. Turn those on and &amp;ldquo;forgot the attribute&amp;rdquo; fails safe out of the box. Go is the honest outlier: with no annotations, the router group &lt;em>is&lt;/em> the declaration, so the discipline is in where a route gets registered. One caveat across all of them: Transaction Tokens are still an IETF draft, so no library ships support for them yet. What teams run today is a standard JWT library (jose, jjwt, golang-jwt, PyJWT) verifying tokens the gateway mints through RFC 8693 token exchange, which IdPs like &lt;a class="link" href="https://www.keycloak.org/securing-apps/token-exchange" target="_blank" rel="noopener"
>Keycloak&lt;/a> already implement.&lt;/p>
&lt;p>These are layers, not alternatives. The chassis is the floor, because it is the one that removes the per-developer failure mode. Route declarations sit on top as the explicit, reviewable record of which calls have a caller, with fail-closed as the rule. Row-level security backstops the data. Separate paths are reserved for the one or two boundaries that justify them. The shape to aim for is the same in every layer: the safe behavior is the default that requires no thought, and the unsafe behavior is a visible declaration someone had to write down and a reviewer got to see.&lt;/p>
&lt;h2 id="whose-request-at-every-hop">Whose Request, at Every Hop
&lt;/h2>&lt;p>The front door is the easy part, and most teams do it well. The work that gets skipped is everything after it, where the request fans out into a dozen internal calls that trust each other because they are inside. A service mesh makes that interior safer, and it is worth running, but it secures the channel. Whose request this is still needs an answer at every hop that touches someone&amp;rsquo;s data.&lt;/p>
&lt;p>Carry the caller. Enforce the caller. Derive the tenant from the caller and never from the call. Do that on the hops that touch tenant-scoped data, and &amp;ldquo;whose request is this, three hops in&amp;rdquo; stops being a question your ledger service cannot answer.&lt;/p></description></item></channel></rss>