<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Kaustav Sarkar</title>
    <description>The latest articles on DEV Community by Kaustav Sarkar (@curioustechie).</description>
    <link>https://dev.to/curioustechie</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4104918%2Fc52cbba7-ad4f-4bf8-91eb-0ee280f516fa.jpeg</url>
      <title>DEV Community: Kaustav Sarkar</title>
      <link>https://dev.to/curioustechie</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/curioustechie"/>
    <language>en</language>
    <item>
      <title>Patterns, Anti-Patterns, and Operational Reality</title>
      <dc:creator>Kaustav Sarkar</dc:creator>
      <pubDate>Tue, 01 Sep 2026 19:10:35 +0000</pubDate>
      <link>https://dev.to/curioustechie/patterns-anti-patterns-and-operational-reality-mjl</link>
      <guid>https://dev.to/curioustechie/patterns-anti-patterns-and-operational-reality-mjl</guid>
      <description>&lt;p&gt;This blog is a part of the Zero-Trust Security on Istio Series, if you haven't read the previous article, please do, it will provide better context.&lt;/p&gt;

&lt;p&gt;The previous posts covered what the architecture does and why each layer exists. This post covers what matters when the system is running: the patterns that compose cleanly, the mistakes that widen blast radius, and the operational work that falls out of the design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern: The Sidecar Contract
&lt;/h2&gt;

&lt;p&gt;The simplest pattern in this architecture is the service contract. A protected application service reads two sidecar-projected headers (&lt;code&gt;x-ms&amp;lt;N&amp;gt;-user&lt;/code&gt;, &lt;code&gt;x-ms&amp;lt;N&amp;gt;-role&lt;/code&gt;) and does its domain logic. It doesn't validate JWTs. It doesn't call Keycloak. It doesn't manage TLS certificates. It doesn't need to know whether the user authenticated with a session cookie or a bearer token.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_ms2_headers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;x_ms2_user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;x_ms2_role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;x_request_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;x_ms2_user&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;x_ms2_role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Missing required legacy headers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;x_ms2_user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;x_ms2_role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;request_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;x_request_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This has practical consequences. To add a protected service, you deploy it with a service account, add destination-side header stripping, configure &lt;code&gt;RequestAuthentication&lt;/code&gt;, write an &lt;code&gt;AuthorizationPolicy&lt;/code&gt;, and read the two projected headers. Gateway-facing services also need a route-to-audience entry in Auth Service. The application still avoids an auth library, OIDC issuer config, and a runtime dependency on Keycloak.&lt;/p&gt;

&lt;p&gt;When the identity infrastructure changes (new signing algorithm, new identity provider, new token format), application code doesn't change. The mesh config updates, and services keep reading the same two headers. Resource checks still live in application code, but the identity input stays stable.&lt;/p&gt;

&lt;p&gt;The service does not trust arbitrary incoming headers. Each destination sidecar has a Lua filter that strips existing &lt;code&gt;x-ms&amp;lt;N&amp;gt;-user&lt;/code&gt; and &lt;code&gt;x-ms&amp;lt;N&amp;gt;-role&lt;/code&gt; headers before JWT validation runs. After stripping, &lt;code&gt;outputClaimToHeaders&lt;/code&gt; writes fresh values from the verified token. If a compromised internal service sends a request with a spoofed &lt;code&gt;x-ms2-user&lt;/code&gt; header, the destination sidecar removes it and replaces it with the value from the signed JWT. The protected service only sees headers authored by its own sidecar.&lt;/p&gt;

&lt;p&gt;The tradeoff is dependency on the mesh. If sidecar injection fails, the service has no independent way to verify identity. &lt;code&gt;PeerAuthentication: STRICT&lt;/code&gt; and workload &lt;code&gt;AuthorizationPolicy&lt;/code&gt; are what make that failure visible instead of silently accepting plaintext traffic. Security is centralized at the infrastructure layer, so the infrastructure has to be treated as part of the application contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern: Namespace Segregation by Function
&lt;/h2&gt;

&lt;p&gt;Four project namespaces, plus &lt;code&gt;istio-system&lt;/code&gt; for the mesh control plane and ingress gateway:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;zt-apps&lt;/code&gt; — application services (Profile Aggregator, Employee Records, Device Inventory, Holiday Calendar, Office Directory, Auth Service)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;zt-identity&lt;/code&gt; — Keycloak (OIDC provider)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;zt-security&lt;/code&gt; — Vault, Cerbos&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;zt-data&lt;/code&gt; — PostgreSQL&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Namespaces are RBAC boundaries, network policy boundaries, and resource quota boundaries. An attacker with &lt;code&gt;kubectl&lt;/code&gt; access scoped to &lt;code&gt;zt-apps&lt;/code&gt; cannot read Secrets in &lt;code&gt;zt-security&lt;/code&gt;, where the POC stores the Vault root token for tests. Auth Service's scoped Vault token does live in &lt;code&gt;zt-apps&lt;/code&gt;, because it needs signing authority at runtime. That token can sign through Transit and read public keys. It cannot rotate keys, configure Vault, or access other secrets engines. Istio's SPIFFE identity includes the namespace, so &lt;code&gt;AuthorizationPolicy&lt;/code&gt; can distinguish "any workload in &lt;code&gt;zt-apps&lt;/code&gt;" from "Auth Service running as &lt;code&gt;auth-service-sa&lt;/code&gt;."&lt;/p&gt;

&lt;p&gt;With everything in &lt;code&gt;default&lt;/code&gt;, accidental privilege broadening is easier. RBAC rules, Secrets, service accounts, and network policy exceptions all share the same namespace scope, so blast radius becomes harder to reason about during reviews.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern: Signed Tokens Over Forwarded Credentials
&lt;/h2&gt;

&lt;p&gt;Auth Service (&lt;code&gt;auth-service&lt;/code&gt;) validates the user's session cookie or bearer token and then mints a short-lived, purpose-specific mesh token. Downstream services do not receive the original credentials. The session cookie and Keycloak JWT stop at the gateway/Auth Service boundary.&lt;/p&gt;

&lt;p&gt;Why this matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;If a downstream service is compromised, the original credential is absent. Depending on the compromise point, the attacker may see projected identity headers or the short-lived mesh assertion in transit, but they do not get the user's session cookie or Keycloak bearer token. The mesh assertion has a 5-minute TTL and still has to match the destination's &lt;code&gt;aud&lt;/code&gt;, &lt;code&gt;act&lt;/code&gt;, and source-principal checks.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The mesh token carries the platform claims this architecture uses: user, role, audience, delegation, groups, department, and request metadata. It does not carry session IDs or refresh tokens.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Each token is scoped to a specific set of destination services via the &lt;code&gt;aud&lt;/code&gt; claim. A token minted for the profile flow (Profile Aggregator, Employee Records, Device Inventory) is invalid at Holiday Calendar or Office Directory.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mistake this avoids is forwarding the original bearer token or session cookie to every downstream service. That gives each service in the call chain access to authentication material that should have stopped at the boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern: Fail-Closed by Default
&lt;/h2&gt;

&lt;p&gt;Protected paths are designed so that missing configuration or failed infrastructure denies access instead of opening it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Protected workloads have scoped &lt;code&gt;ALLOW&lt;/code&gt; &lt;code&gt;AuthorizationPolicy&lt;/code&gt; resources. In Istio, traffic is allowed by default when no policy targets a workload. Once an &lt;code&gt;ALLOW&lt;/code&gt; policy applies, unmatched requests are denied.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Auth Service down? ExtAuthz cannot approve protected routes, so the gateway returns 401, 403, or 503 depending on where the failure surfaces.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Vault unreachable? Auth Service cannot mint a new mesh token. The JWKS endpoint returns 500 if it cannot read public keys from Vault, while sidecars may keep using cached keys until refresh.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Cerbos unreachable? Employee Records and Device Inventory fail closed and return 403. Holiday Calendar and Office Directory allow read-only public-data actions during Cerbos outages and deny writes. That is a product decision, not a universal security property.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Sidecar not injected? &lt;code&gt;PeerAuthentication: STRICT&lt;/code&gt; means mesh peers reject plaintext connections to that pod. Traffic from outside the mesh still needs Kubernetes &lt;code&gt;NetworkPolicy&lt;/code&gt; or another boundary if the cluster allows it.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is verified by a negative-path test that scales the Auth Service deployment to zero replicas and confirms that protected requests do not return 200:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Scale auth to 0, confirm requests fail-closed&lt;/span&gt;
kubectl scale deployment auth-service &lt;span class="nt"&gt;-n&lt;/span&gt; zt-apps &lt;span class="nt"&gt;--replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="nb"&gt;sleep &lt;/span&gt;10
&lt;span class="nv"&gt;response&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}"&lt;/span&gt; https://app.localtest.me/api/profile/1&lt;span class="si"&gt;)&lt;/span&gt;
assert_not_equal &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"200"&lt;/span&gt;
kubectl scale deployment auth-service &lt;span class="nt"&gt;-n&lt;/span&gt; zt-apps &lt;span class="nt"&gt;--replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For protected routes, a failure that returns application data is a security bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anti-Pattern: mTLS-Only Security
&lt;/h2&gt;

&lt;p&gt;mTLS verifies that the calling pod is who it claims to be (via its SPIFFE certificate) and encrypts the connection. That's necessary but not sufficient.&lt;/p&gt;

&lt;p&gt;If Holiday Calendar is compromised and your only security layer is mTLS, the attacker has a valid mesh certificate. They can make encrypted, authenticated connections to any other service in the mesh. mTLS tells Employee Records "this connection is from Holiday Calendar," but without AuthorizationPolicy, Employee Records accepts it. The encrypted tunnel between a compromised pod and a sensitive service is still a compromised path.&lt;/p&gt;

&lt;p&gt;mTLS is the foundation. It's the transport layer identity that AuthorizationPolicy references. But treating it as the entire security model means any single pod compromise gives the attacker lateral movement across the entire mesh.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anti-Pattern: Security in Application Code
&lt;/h2&gt;

&lt;p&gt;One common alternative is implementing identity verification and coarse authorization in every service. Each service validates JWTs, wires its own policy checks, and owns the failure behavior for missing or malformed credentials.&lt;/p&gt;

&lt;p&gt;The problems compound at scale:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;One service has a bug in JWT validation. Maybe it doesn't check &lt;code&gt;exp&lt;/code&gt;. Maybe it accepts tokens from the wrong issuer. That service is now a bypass.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The signing algorithm needs to change. You roll it out to 47 services. Three of them are on the old version for a week because the team is busy. During that week, your security posture is inconsistent.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A new developer adds an endpoint and forgets the auth middleware. The code review misses it. That endpoint is now unprotected, and the mesh has no way to catch it because it doesn't know the endpoint should be protected.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With mesh-based security, identity verification and coarse enforcement (mTLS, &lt;code&gt;AuthorizationPolicy&lt;/code&gt;, header projection) are expressed in mesh config and enforced by the sidecar. The application developer does not need to remember those checks on every endpoint because they run before application code. Resource-level authorization (Cerbos calls) and response shaping (field masking) still live in application code, but the identity those decisions rely on comes from the sidecar.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anti-Pattern: Flat Trust Zones
&lt;/h2&gt;

&lt;p&gt;The cluster-internal trust assumption is simple: anything inside the cluster can talk to anything else. The perimeter becomes the main security boundary.&lt;/p&gt;

&lt;p&gt;A single compromised pod then becomes a useful foothold. Container escapes, supply-chain issues, and vulnerable dependencies all put the attacker inside the trusted zone. Without service-to-service policy, the network gives them too many next hops.&lt;/p&gt;

&lt;p&gt;The tiered architecture limits those next hops. Tier 1 can reach the specific Tier 2 services it needs. Approved services can reach PostgreSQL. Reverse and lateral paths are not part of the allow-list. Istio &lt;code&gt;AuthorizationPolicy&lt;/code&gt; enforces that at each destination workload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational Reality: Debugging Auth Failures
&lt;/h2&gt;

&lt;p&gt;Auth failures are easiest to debug by locating the first boundary that rejected the request. The same HTTP status can come from different layers, so start with where the request stopped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;401, 403, or 503 from the gateway:&lt;/strong&gt; ExtAuthz denied the request, Auth Service was unavailable, Vault signing failed, or the route was not in the coarse policy map. Check the ingress gateway response code first, then Auth Service logs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;401 from the destination service:&lt;/strong&gt; The sidecar validated the JWT but the service didn't find projected headers. This usually means &lt;code&gt;RequestAuthentication&lt;/code&gt; is misconfigured (wrong &lt;code&gt;fromHeaders&lt;/code&gt; name, wrong &lt;code&gt;outputClaimToHeaders&lt;/code&gt; claim path) or the JWT is missing the expected claims.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;403 before the destination service logs anything:&lt;/strong&gt; The mTLS identity or JWT claims did not match &lt;code&gt;AuthorizationPolicy&lt;/code&gt;. Check &lt;code&gt;istioctl proxy-config listener &amp;lt;pod&amp;gt;&lt;/code&gt; and the policy &lt;code&gt;when&lt;/code&gt; conditions. A common cause is a token whose &lt;code&gt;aud&lt;/code&gt; claim does not include the destination service name.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cerbos denial:&lt;/strong&gt; The user passed the route and network checks, but the resource-level policy denied the action. Check Cerbos with the exact principal, resource, action, and resource attributes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Empty result set (RLS):&lt;/strong&gt; The query returns zero rows. Check what &lt;code&gt;app.current_user_id&lt;/code&gt; was set to on the transaction and compare with the RLS policy conditions.&lt;/p&gt;

&lt;p&gt;The rule of thumb: start from the gateway and move inward. If the request never reaches the service, investigate ExtAuthz, routing, JWT validation, and &lt;code&gt;AuthorizationPolicy&lt;/code&gt;. If it reaches the service, investigate projected headers, Cerbos inputs, and RLS transaction context.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh6e5tx1ieop1vm3i84n2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh6e5tx1ieop1vm3i84n2.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational Reality: Sidecar Overhead
&lt;/h2&gt;

&lt;p&gt;Every pod in this mesh runs an Envoy sidecar. That adds CPU and memory per pod, latency per request path, and one more place to inspect when behavior does not match the application logs.&lt;/p&gt;

&lt;p&gt;For this POC on a Kind cluster, the overhead is negligible. In production, it is a real cost. Istio ambient mesh (&lt;code&gt;ztunnel&lt;/code&gt; + waypoint proxies) addresses part of that cost by moving L4 processing to a node-level daemon and making L7 processing opt-in.&lt;/p&gt;

&lt;p&gt;The tradeoff is where L7 policy runs. Sidecar-per-pod gives each workload local L7 enforcement. Ambient mesh moves L4 security and policy to &lt;code&gt;ztunnel&lt;/code&gt;; L7 features such as path and method matching, &lt;code&gt;CUSTOM&lt;/code&gt; authorization, JWT claim handling, and header projection require waypoint proxies. This architecture depends on L7 behavior at the gateway and protected service hops, so an ambient version would need waypoints for those workloads. The resource savings come from workloads that only need L4 mTLS, peer identity, and connection-level authorization.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational Reality: Testing Security Properties
&lt;/h2&gt;

&lt;p&gt;The tests that matter most verify that incorrect behavior is denied. The main negative-path script checks these cases:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Unauthenticated access to protected routes returns 401/403.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;External requests with spoofed internal headers are rejected.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Direct access to Tier 2 services from the gateway is impossible.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A non-Profile Aggregator pod calling Employee Records in-cluster is rejected.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Tokens with wrong audience claims are rejected.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Expired tokens are rejected.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Profile Aggregator cannot connect directly to PostgreSQL.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Auth Service outage fails closed for protected routes.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each test encodes a specific assumption about the security model. If any test returns application data where denial is expected, a security property has been violated. The repository also has a broader 12-category attack-test harness for spoofing, JWT abuse, token replay, path normalization, SSRF, RLS leakage, ExtAuthz fail-open behavior, Cerbos spoofing, mTLS, and Istio config audit. For this POC, I treat these as local release-gate scripts; production needs them wired into CI before mesh config changes can be treated as guarded.&lt;/p&gt;

&lt;p&gt;The rotation test verifies the mint-and-rotate path: mint a token, record its &lt;code&gt;kid&lt;/code&gt;, rotate the Vault Transit key, mint another token, and confirm the new &lt;code&gt;kid&lt;/code&gt; changed. That proves Auth Service fetches the current key version at mint time. It does not, by itself, prove every sidecar refreshed JWKS without a temporary rejection window.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Vault storage:&lt;/strong&gt; &lt;code&gt;emptyDir&lt;/code&gt; means Vault's state is lost on pod restart. The bootstrap job re-initializes everything, which works for a POC but is unacceptable for production. Persistent storage with auto-unseal is the minimum for real use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;JWKS caching:&lt;/strong&gt; Auth Service fetches public keys from Vault on every JWKS request. Under load, this is unnecessary. A short-lived cache with forced invalidation on rotation would reduce Vault load. The cache duration should be tied to the token TTL and the rotation process, because stale JWKS is part of the key-rotation failure mode.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token TTL tuning:&lt;/strong&gt; 5 minutes is conservative. For internal-only calls from Profile Aggregator to Employee Records, a 60-second TTL would be sufficient and would reduce the replay window. The tradeoff is more frequent token minting for slower operations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ambient mesh:&lt;/strong&gt; I would evaluate ambient mesh service by service. Workloads that only need mTLS and peer authorization are good candidates for &lt;code&gt;ztunnel&lt;/code&gt; without sidecars. Workloads that depend on JWT validation, header projection, path/method policy, or ExtAuthz need waypoints. In this architecture, most protected paths depend on L7 behavior, so ambient mesh would reduce overhead only where the workload boundary can stay at L4.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;Five layers. Eight request checkpoints. The config is complex, but the idea is simple: every hop verifies independently, each layer covers a different failure mode, and application code stays focused on domain logic.&lt;/p&gt;

&lt;p&gt;The patterns that make it work are all forms of separation: security enforcement from application code, identity creation from identity verification, coarse authorization from fine-grained authorization, network boundaries from data boundaries. Each separation limits how far one mistake can propagate.&lt;/p&gt;

&lt;p&gt;The anti-patterns all collapse those separations: treating one layer as sufficient, spreading identity verification across services, or trusting cluster networking as the security boundary.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmxnhnzw49ok7c8sw1j35.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmxnhnzw49ok7c8sw1j35.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/Kaustav-Sarkar/Istio-Defense-In-Depth" rel="noopener noreferrer"&gt;https://github.com/Kaustav-Sarkar/Istio-Defense-In-Depth&lt;/a&gt;&lt;/p&gt;

</description>
      <category>microservices</category>
      <category>kubernetes</category>
      <category>cybersecurity</category>
      <category>security</category>
    </item>
    <item>
      <title>Layered Authorization: Network to Database</title>
      <dc:creator>Kaustav Sarkar</dc:creator>
      <pubDate>Tue, 01 Sep 2026 19:06:31 +0000</pubDate>
      <link>https://dev.to/curioustechie/layered-authorization-network-to-database-3cmm</link>
      <guid>https://dev.to/curioustechie/layered-authorization-network-to-database-3cmm</guid>
      <description>&lt;p&gt;This blog is a part of the Zero-Trust Security on Istio Series, if you haven't read the previous article, please do, it will provide better context.&lt;/p&gt;

&lt;p&gt;The previous post covered how Auth Service signs the mesh identity token that carries a user through the mesh. Once a request arrives with a verified identity, the next question is what that identity is allowed to do.&lt;/p&gt;

&lt;p&gt;Authentication tells you who someone is. Authorization tells you what they're allowed to do. In this architecture, authorization runs at four granularities: network, request, resource, and field. Each operates at a different enforcement point with different information available, and each catches failures the others can't. A separate layer, PostgreSQL row-level security, sits at the database and backstops all of them when application code has a bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Four Granularities
&lt;/h2&gt;

&lt;p&gt;A single policy engine can't efficiently handle every authorization decision. Some decisions require only the service identity (which pod is calling). Others require the user's role. Others require knowing the specific resource being accessed and the relationship between the user and that resource. And field visibility only makes sense at query time, when you know the actual data attributes.&lt;/p&gt;

&lt;p&gt;Different information is available at different enforcement points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;At the sidecar: mTLS identity and JWT claims. No application context. This is where network-level authorization runs (Istio AuthorizationPolicy).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;At the gateway: user role and route. No specific resource. This is where request-level authorization runs (ExtAuthz).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;At the application: full request context, resource attributes, user-resource relationships. This is where Cerbos makes resource-level and field-level decisions.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;At the database: transaction-level identity. No application logic. This is where row-level security runs. Strictly it's a data-access control rather than authorization, and it's the final enforcement point that backstops the others.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Trying to push all decisions to one point either means that point needs all context (which defeats the separation) or that some decisions can't be made at all.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2For9vsc2nh3aycfzrnwrl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2For9vsc2nh3aycfzrnwrl.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Network (Sidecar): Istio AuthorizationPolicy
&lt;/h2&gt;

&lt;p&gt;The first question: can this service even talk to that service?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;security.istio.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AuthorizationPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;allow-ms1-to-ms2&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;zt-apps&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ms2-employee-details&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ALLOW&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;principals&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;cluster.local/ns/zt-apps/sa/ms1-profile-aggregator-sa&lt;/span&gt;
      &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;operation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8000"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;request.auth.claims[aud]&lt;/span&gt;
          &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ms2-employee-details"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;request.auth.claims[act][sub]&lt;/span&gt;
          &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ms1-profile-aggregator"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This policy says: only Profile Aggregator's service account (&lt;code&gt;ms1-profile-aggregator-sa&lt;/code&gt;) can reach Employee Records, and only with a token that has the correct audience and delegation claims. Everything else is denied.&lt;/p&gt;

&lt;p&gt;Important nuance: Istio's default behavior when &lt;em&gt;no&lt;/em&gt; AuthorizationPolicy targets a workload is to allow all traffic. Default-deny only kicks in once at least one ALLOW policy exists for that workload. This means every service that should be protected needs an explicit policy. A newly deployed service without a matching policy is open until you write one. In this architecture, every workload in &lt;code&gt;zt-apps&lt;/code&gt; has at least one scoped ALLOW policy, which triggers deny-by-default for unmatched requests.&lt;/p&gt;

&lt;p&gt;What this catches: lateral movement. If Holiday Calendar is compromised, the attacker cannot reach Employee Records. The connection is refused at the sidecar level before any application code processes the request. The attacker would need both the correct service account identity (the mTLS certificate) and a validly-signed token with the right claims.&lt;/p&gt;

&lt;p&gt;The full boundary map:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Can reach&lt;/th&gt;
&lt;th&gt;Cannot reach&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ingress Gateway&lt;/td&gt;
&lt;td&gt;Profile Aggregator, Holiday Calendar, Office Directory, Auth Service&lt;/td&gt;
&lt;td&gt;Employee Records, Device Inventory, Vault, Postgres&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Profile Aggregator&lt;/td&gt;
&lt;td&gt;Employee Records, Device Inventory&lt;/td&gt;
&lt;td&gt;Cerbos, Vault, Keycloak, Postgres&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Employee Records, Device Inventory&lt;/td&gt;
&lt;td&gt;Cerbos, Postgres (own schema only)&lt;/td&gt;
&lt;td&gt;Profile Aggregator, Holiday Calendar, Office Directory, Vault&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Holiday Calendar, Office Directory&lt;/td&gt;
&lt;td&gt;Cerbos, Postgres (own schema only)&lt;/td&gt;
&lt;td&gt;Profile Aggregator, Employee Records, Device Inventory, Vault&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth Service&lt;/td&gt;
&lt;td&gt;Vault, Postgres&lt;/td&gt;
&lt;td&gt;Employee Records, Device Inventory, Holiday Calendar, Office Directory&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every cell in this table is an explicit decision. Unmatched traffic is denied because each workload has at least one ALLOW policy targeting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Request (Gateway): ExtAuthz
&lt;/h2&gt;

&lt;p&gt;The second question: is this user, with this role, allowed to call this route?&lt;/p&gt;

&lt;p&gt;Auth Service (&lt;code&gt;auth-service&lt;/code&gt;) acts as an external authorization check at the gateway. Before minting a mesh token, it checks a route-role policy map:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Route&lt;/th&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Allowed Roles&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/profile/*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;any&lt;/td&gt;
&lt;td&gt;employee, manager, hr_admin, it_admin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/holidays&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;GET&lt;/td&gt;
&lt;td&gt;employee, manager, hr_admin, it_admin, public_data_admin, security_auditor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/holidays&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;POST/PUT/DELETE&lt;/td&gt;
&lt;td&gt;hr_admin, public_data_admin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/offices/*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;any&lt;/td&gt;
&lt;td&gt;&lt;em&gt;(bypasses ExtAuthz entirely; see below)&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The system has six roles total (&lt;code&gt;employee&lt;/code&gt;, &lt;code&gt;manager&lt;/code&gt;, &lt;code&gt;hr_admin&lt;/code&gt;, &lt;code&gt;it_admin&lt;/code&gt;, &lt;code&gt;public_data_admin&lt;/code&gt;, &lt;code&gt;security_auditor&lt;/code&gt;). The core access model for the profile/HR flow is built around the first four. &lt;code&gt;public_data_admin&lt;/code&gt; manages office and holiday data. &lt;code&gt;security_auditor&lt;/code&gt; has read access to non-sensitive endpoints.&lt;/p&gt;

&lt;p&gt;Office routes are the exception: the gateway's &lt;code&gt;AuthorizationPolicy&lt;/code&gt; exempts &lt;code&gt;/api/offices&lt;/code&gt; and &lt;code&gt;/api/offices/*&lt;/code&gt; from ExtAuthz entirely. Reads are public, and write authorization (&lt;code&gt;public_data_admin&lt;/code&gt; only) is enforced downstream by Cerbos at Office Directory, not at the gateway. Everything else routes through the map above.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;public_data_admin&lt;/code&gt; trying to access the profile endpoint gets a 403 at the gateway. They never hit Profile Aggregator. The token is never minted. Downstream services never see the request.&lt;/p&gt;

&lt;p&gt;This is coarse authorization. It knows the user's role and the route they're hitting, but it knows nothing about which specific resource they want to access. A &lt;code&gt;manager&lt;/code&gt; can access the profile endpoint, but that doesn't mean they should see every employee's salary; they should only see pay data for their own direct reports. That's a different layer's job.&lt;/p&gt;

&lt;p&gt;The key decision here is what gets denied early (saving downstream resources and reducing attack surface) versus what gets deferred to finer-grained layers that have more context.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resource and Field (Application): Cerbos
&lt;/h2&gt;

&lt;p&gt;The third and fourth granularities: can this user access &lt;em&gt;this specific resource&lt;/em&gt;? And which fields should they see?&lt;/p&gt;

&lt;p&gt;Cerbos evaluates context-aware policies. It receives the principal (user ID, roles, attributes), the resource (type, ID, owner, attributes), and the action. It returns an allow/deny decision and a list of visible fields.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# cerbos/policies/employee_profile.yaml (abbreviated: view/list and hr_admin rules omitted)&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api.cerbos.dev/v1&lt;/span&gt;
&lt;span class="na"&gt;resourcePolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
  &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;employee_profile&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;view_sensitive"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;update"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;effect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;EFFECT_ALLOW&lt;/span&gt;
      &lt;span class="na"&gt;roles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;employee"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;request.principal.id == request.resource.attr.id&lt;/span&gt;
      &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|-&lt;/span&gt;
          &lt;span class="s"&gt;{"visible_fields": ["name", "title", "department", "salary_band", "base_salary", "ssn"]}&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;view_sensitive"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;effect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;EFFECT_ALLOW&lt;/span&gt;
      &lt;span class="na"&gt;roles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;manager"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;request.principal.id == request.resource.attr.manager_id&lt;/span&gt;
      &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|-&lt;/span&gt;
          &lt;span class="s"&gt;{"visible_fields": ["name", "title", "department", "salary_band"]}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this means concretely: Alice (employee) views her own profile and sees everything including salary and SSN. Bob (manager) views Alice's profile and sees the salary band but not the exact salary or SSN. Carol (another employee) views Alice's profile and sees only name, title, and department.&lt;/p&gt;

&lt;p&gt;Same endpoint. Same authentication. Same route-level authorization. Different data, determined by the relationship between the user and the resource.&lt;/p&gt;

&lt;p&gt;What Cerbos catches that the previous layers cannot: role alone isn't sufficient. A manager should see sensitive data for their direct reports, not for every employee in the company. This requires knowing who the resource belongs to and what the manager's relationship is to that person. The gateway doesn't have that information. Only the application, at query time, knows these relationships.&lt;/p&gt;

&lt;h3&gt;
  
  
  Field Masking
&lt;/h3&gt;

&lt;p&gt;Cerbos doesn't filter data itself. It returns a list of visible fields, and the application code uses that list to shape the response. This is a contract between policy and application:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cerbos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;check_resource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;roles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;
    &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kind&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;employee_profile&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;employee_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;manager_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;...}},&lt;/span&gt;
    &lt;span class="n"&gt;actions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;view_sensitive&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;visible&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;outputs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;visible_fields&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="c1"&gt;# Application filters the response to only include these fields
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application must respect this output. If it doesn't, row-level security on the sensitive tables (PII and financials) still limits which rows the database returns, though the directory table is open and relies on Cerbos for field masking. Field-level masking specifically depends on correct application implementation. This is one place where the "security as infrastructure" model has a boundary. The mesh can't mask JSON response fields for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Database (Backstop): PostgreSQL Row-Level Security
&lt;/h2&gt;

&lt;p&gt;The final question: even if everything above passes, should the database return this row?&lt;/p&gt;

&lt;p&gt;Row-level security here is deliberately split. The employee directory table (&lt;code&gt;hr.employees&lt;/code&gt;, holding name, title, department, manager) is readable by any authenticated principal, because the directory itself isn't sensitive and Cerbos handles field masking on top of it. The sensitive tables (&lt;code&gt;hr.employee_pii&lt;/code&gt;, &lt;code&gt;hr.employee_financials&lt;/code&gt;) carry the strict row-level policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;employee_financials_visibility&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;hr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;employee_financials&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.current_roles'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%hr_admin%'&lt;/span&gt;
    &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;employee_id&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.current_user_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;hr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;employees&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
      &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;employee_id&lt;/span&gt;
        &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;manager_id&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.current_user_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;hr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;employee_financials&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;hr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;employee_financials&lt;/span&gt; &lt;span class="k"&gt;FORCE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This policy says: you can see a financial record if you're an &lt;code&gt;hr_admin&lt;/code&gt;, if it's your own, or if you manage that employee. Nothing else. A query with no WHERE clause still returns only the rows this user is allowed to see. The PII table (&lt;code&gt;hr.employee_pii&lt;/code&gt;) uses the same shape, and &lt;code&gt;it.hardware_assets&lt;/code&gt; uses self-or-&lt;code&gt;it_admin&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;RLS uses &lt;code&gt;current_setting('app.current_user_id')&lt;/code&gt; and &lt;code&gt;current_setting('app.current_roles')&lt;/code&gt;, which are set by the application at the start of each transaction from the sidecar-projected headers. The database has no knowledge of JWTs, sidecars, or mesh tokens. It just enforces visibility based on the transaction context it's given.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Cerbos and RLS
&lt;/h3&gt;

&lt;p&gt;The two overlap, but they catch different failures:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cerbos fails (policy bug, service misconfiguration, Cerbos unreachable):&lt;/strong&gt; The services fail closed. If Cerbos is unreachable, the application returns 403 (the client catches all Cerbos errors and denies by default). If a policy bug returns an incorrect allow decision, RLS on the sensitive tables still drops the PII and financial rows the user can't see. The response might leak directory fields that should have been masked, but the sensitive records stay protected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Application fails (developer forgets to call Cerbos, broken filter logic, new endpoint without an auth check):&lt;/strong&gt; RLS still enforces on the sensitive tables. &lt;code&gt;SELECT * FROM hr.employee_financials&lt;/code&gt; without any Cerbos check still returns only rows matching the RLS policy. The mistake doesn't become a financial-data breach.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RLS fails (misconfigured policy, wrong user ID set on transaction):&lt;/strong&gt; The query order is: the service loads the employee record first (to obtain attributes like &lt;code&gt;manager_id&lt;/code&gt; for the Cerbos policy), then calls Cerbos, then queries the sensitive tables only after Cerbos approves. That initial directory read is intentionally open, so Cerbos is what gates the sensitive query. A broken sensitive-table RLS policy widens which rows come back, but Cerbos already gated access to those tables. A broken Cerbos policy widens what's returned, but RLS still limits sensitive-row visibility.&lt;/p&gt;

&lt;p&gt;Neither Cerbos nor RLS alone is sufficient. Together, an application developer's mistake stops short of full exposure of PII or financial data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Schema Isolation
&lt;/h3&gt;

&lt;p&gt;RLS is complemented by PostgreSQL GRANT-level isolation. Each service has a dedicated database role:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;ms2_hr_role&lt;/code&gt; can only access &lt;code&gt;hr.*&lt;/code&gt; tables (employees, PII, financials)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;ms3_it_role&lt;/code&gt; can only access &lt;code&gt;it.hardware_assets&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;ms4_public_readwrite_role&lt;/code&gt; can only access &lt;code&gt;public_data.*&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even without RLS, Device Inventory physically cannot query &lt;code&gt;hr.employee_financials&lt;/code&gt;. The GRANT isn't there. This is a separate enforcement layer from RLS. If Device Inventory is compromised and the attacker somehow bypasses Istio AuthorizationPolicy (they'd need the mTLS cert and a valid token), the database still won't serve them HR data because the connection role lacks the privilege.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Granularities Compose: A Concrete Example
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx5a0h49fh0367de6grbc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx5a0h49fh0367de6grbc.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Alice (employee) requests her own profile with &lt;code&gt;GET /api/profile/{alice_id}&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Network:&lt;/strong&gt; Gateway can reach Profile Aggregator. Profile Aggregator can reach Employee Records. ✓&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Request:&lt;/strong&gt; Alice has the &lt;code&gt;employee&lt;/code&gt; role, &lt;code&gt;/api/profile/*&lt;/code&gt; allows employees. Token minted. ✓&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Resource and field (Cerbos):&lt;/strong&gt; Alice is requesting her own record (&lt;code&gt;principal.id == resource.attr.id&lt;/code&gt;). Allowed, with visible fields that include salary and SSN.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Database (RLS):&lt;/strong&gt; When the app reads her financials and PII, &lt;code&gt;app.current_user_id&lt;/code&gt; matches the &lt;code&gt;employee_id&lt;/code&gt; on those rows, so they're returned.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Bob (employee) tries to see Alice's financial data. His request also comes in as &lt;code&gt;GET /api/profile/{alice_id}&lt;/code&gt;, and Profile Aggregator fans out toward Alice's sensitive records:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Network:&lt;/strong&gt; Same path. ✓&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Request:&lt;/strong&gt; Bob has the &lt;code&gt;employee&lt;/code&gt; role, &lt;code&gt;/api/profile/*&lt;/code&gt; allows employees. Token minted. ✓&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Resource and field (Cerbos):&lt;/strong&gt; When the app tries to read Alice's financials, it checks the &lt;code&gt;view_sensitive&lt;/code&gt; action. Bob is not Alice and not Alice's manager. Denied. The &lt;code&gt;/financials&lt;/code&gt; and &lt;code&gt;/pii&lt;/code&gt; endpoints on Employee Records are all-or-nothing, so the response is a 403 with no partial data.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Database (RLS), if Cerbos had a bug and allowed it:&lt;/strong&gt; The RLS policy on &lt;code&gt;hr.employee_financials&lt;/code&gt; requires &lt;code&gt;employee_id == current_user_id&lt;/code&gt; or that the caller manages the employee. Bob is neither. Zero rows returned. The bug at the resource layer is caught at the database.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Bob viewing Alice's basic profile is a different action on the directory: the &lt;code&gt;view&lt;/code&gt; action is allowed for any employee, and Cerbos returns &lt;code&gt;visible_fields: [name, title, department]&lt;/code&gt;. Same request path, separate actions, separate policies, and the directory row is readable while the sensitive tables are not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;The final post covers the patterns and anti-patterns across all layers: what makes this architecture compose well, what operational pain points exist, and what you'd do differently at scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/Kaustav-Sarkar/Istio-Defense-In-Depth" rel="noopener noreferrer"&gt;https://github.com/Kaustav-Sarkar/Istio-Defense-In-Depth&lt;/a&gt;&lt;/p&gt;

</description>
      <category>authorization</category>
      <category>cybersecurity</category>
      <category>kubernetes</category>
      <category>istio</category>
    </item>
    <item>
      <title>Signing Mesh Tokens with Hashicorp Vault</title>
      <dc:creator>Kaustav Sarkar</dc:creator>
      <pubDate>Tue, 01 Sep 2026 19:01:59 +0000</pubDate>
      <link>https://dev.to/curioustechie/signing-mesh-tokens-with-hashicorp-vault-l1j</link>
      <guid>https://dev.to/curioustechie/signing-mesh-tokens-with-hashicorp-vault-l1j</guid>
      <description>&lt;p&gt;This blog is a part of the Zero-Trust Security on Istio Series, if you haven't read the previous article, please do, it will provide better context.&lt;/p&gt;

&lt;p&gt;The previous post traced one request's identity flow from gateway to database. It left one piece unexplained: how Auth Service actually signs the identity token that carries a user through the mesh. That signing step is the system's root of trust. Steal the signing key and you can forge a token for any user and any role, and every downstream service will believe it.&lt;/p&gt;

&lt;p&gt;A quick recap, so this post stands on its own. The system is an HR employee directory running on Istio. A gateway-facing service, Profile Aggregator, fans out to internal-only services like Employee Records (HR data) and Device Inventory (hardware assignments). Auth Service (&lt;code&gt;auth-service&lt;/code&gt;) sits at the gateway: it authenticates the external user and mints a short-lived signed JWT, &lt;code&gt;x-mesh-identity&lt;/code&gt; (the "mesh token"), that downstream service sidecars verify on every request. It signs that token with Vault, HashiCorp's secrets manager, using Vault's Transit engine, so the private key never touches application code. This post covers why that matters, how it works, and what happens when things go wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: Secrets in Pods
&lt;/h2&gt;

&lt;p&gt;The default Kubernetes pattern for secrets is to mount them into pods. Environment variables, files in &lt;code&gt;/var/run/secrets&lt;/code&gt;, ConfigMaps with sensitive values. Assume any compromised pod leaks whatever secrets it holds. If your signing key is in the Auth Service environment, a container escape gives the attacker the key. They can mint tokens for any user, any role, indefinitely, from anywhere, until you detect the breach and rotate.&lt;/p&gt;

&lt;p&gt;The blast radius of a compromised pod should be limited to what that pod can do right now, not what it can do forever with stolen material.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Vault Solves, and What It Does Not
&lt;/h2&gt;

&lt;p&gt;Token signing is only one use case. In a production cluster, Vault is the central control point for secret access, audit, and rotation across the system. The larger pattern is that applications should not carry durable secret material when the platform can issue short-lived authority from workload identity.&lt;/p&gt;

&lt;p&gt;The production version removes static Vault tokens from application config. Auth Service runs under a dedicated Kubernetes service account with a projected, short-lived service-account token. Vault Kubernetes Auth maps that service account to the narrow signing policy. A Vault Agent sidecar authenticates, renews the resulting Vault token, and keeps the raw token inside the Agent boundary. Auth Service calls Vault through the Agent instead of reading a long-lived token from its own environment.&lt;/p&gt;

&lt;p&gt;There is still a credential. The difference is that it is platform-issued workload identity, not a manually provisioned Vault token sitting in app config. The remaining credential is issued by Kubernetes, rotated by the platform, scoped by Vault policy, and auditable at Vault.&lt;/p&gt;

&lt;p&gt;This POC uses the simpler version: Auth Service receives &lt;code&gt;VAULT_TOKEN&lt;/code&gt; directly. That keeps the signing flow easy to see, but it is a demo shortcut, not the endpoint of the pattern.&lt;/p&gt;

&lt;p&gt;Vault also does not make a compromised pod harmless. If the Auth Service pod is compromised, the attacker can use whatever runtime authority that pod has until you revoke or cut it off. What Vault changes is the value of that authority. Instead of stealing the RSA private key and signing offline forever, the attacker gets access to a narrow, revocable, auditable signing path. The private key stays inside Vault, every signing operation goes through a central API, and rotation is controlled in one place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vault Transit: Signing Without Holding Keys
&lt;/h2&gt;

&lt;p&gt;Vault's Transit engine is a cryptography-as-a-service API. You send it bytes, it signs them with a key it manages, and returns the signature. The key material never leaves Vault's control.&lt;/p&gt;

&lt;p&gt;In this architecture, Auth Service uses Transit for one specific operation: signing mesh identity tokens with RS256 (RSA-2048, PKCS#1 v1.5).&lt;/p&gt;

&lt;p&gt;The flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Auth Service builds the JWT header and payload (claims assembled from the authentication result).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Auth Service encodes the signing input: &lt;code&gt;base64url(header).base64url(payload)&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Auth Service sends those bytes to Vault: &lt;code&gt;POST /v1/transit/sign/mesh-identity&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Vault signs with the current RSA private key, returns &lt;code&gt;vault:v&amp;lt;version&amp;gt;:&amp;lt;base64_signature&amp;gt;&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Auth Service strips the prefix, re-encodes as base64url, and assembles the final JWT.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiasuy9xik5nbk9mbi1ty.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiasuy9xik5nbk9mbi1ty.png" alt="Vault Transit" width="800" height="533"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sign_payload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload_bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Signs a payload using Vault Transit and returns the raw signature&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;VAULT_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/v1/transit/sign/mesh-identity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-Vault-Token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;VAULT_TOKEN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;encoded_payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload_bytes&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;encoded_payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hash_algorithm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sha2-256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;signature_algorithm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pkcs1v15&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;# Vault returns the signature as "vault:v&amp;lt;version&amp;gt;:&amp;lt;base64sig&amp;gt;"; strip the prefix for JWS.
&lt;/span&gt;    &lt;span class="n"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;signature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;sig&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two Vault round-trips happen per token mint: one to fetch the current key version (for the &lt;code&gt;kid&lt;/code&gt; header claim), one to sign. In this POC, Auth Service mints a fresh mesh token for every protected gateway request. That keeps the behavior simple and security-first: every request gets a new &lt;code&gt;iat&lt;/code&gt;, &lt;code&gt;exp&lt;/code&gt;, &lt;code&gt;jti&lt;/code&gt;, and signing decision, and key rotation is observed immediately.&lt;/p&gt;

&lt;p&gt;The tradeoff is that Vault sits in the synchronous request path. Under production load, you may choose to cache mesh tokens inside Auth Service until their 5-minute expiry, keyed by session, subject, roles, audience, and delegation context. The token still stays inside the platform boundary: it is not returned to the browser, stored in a user cookie, or exposed to frontend code. Caching removes most signing calls for repeated requests from the same user flow.&lt;/p&gt;

&lt;p&gt;The cost is freshness: revocation, role changes, and key rotation take effect at token-expiry boundaries instead of per request.&lt;/p&gt;

&lt;p&gt;Caching also constrains the token shape. Keep request-specific data such as &lt;code&gt;x-request-id&lt;/code&gt; outside the token so the same mesh assertion can safely travel with multiple requests. That would require changing the current POC token, because this implementation includes &lt;code&gt;request_id&lt;/code&gt; in the JWT payload.&lt;/p&gt;

&lt;h2&gt;
  
  
  The JWKS Endpoint: Public Key Distribution
&lt;/h2&gt;

&lt;p&gt;Every service sidecar needs to verify mesh tokens. Auth Service exposes &lt;code&gt;GET /auth/jwks&lt;/code&gt;, which fetches all active public key versions from Vault and formats them as a standard RFC 7517 JWKS response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_jwks&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Format Vault public keys as JWKS&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;vault_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_public_keys&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;jwks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;keys&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key_info&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;public_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;serialization&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load_pem_public_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;public_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;public_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RSAPublicKey&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;numbers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;public_key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;public_numbers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;jwks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;keys&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kty&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RSA&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;use&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sig&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RS256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;int_to_base64url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;numbers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;e&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;int_to_base64url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;numbers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;jwks&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's no version filtering in this code, and none is needed. &lt;code&gt;get_public_keys()&lt;/code&gt; reads the &lt;code&gt;keys&lt;/code&gt; map from &lt;code&gt;vault read transit/keys/mesh-identity&lt;/code&gt;, and Vault only returns the &lt;em&gt;working set&lt;/em&gt; there: versions from &lt;code&gt;min_decryption_version&lt;/code&gt; through the latest. Anything below &lt;code&gt;min_decryption_version&lt;/code&gt; is archived and dropped from that response, so it never reaches the JWKS output. The filtering happens inside Vault, not in application code. That makes &lt;code&gt;min_decryption_version&lt;/code&gt; the single switch controlling which keys sidecars will accept, and it's why rotation and invalidation are two separate operations, covered next.&lt;/p&gt;

&lt;p&gt;This cluster runs istiod with &lt;code&gt;PILOT_JWT_ENABLE_REMOTE_JWKS&lt;/code&gt; enabled, so each Envoy sidecar fetches this endpoint directly over mTLS, rather than istiod fetching it once and pushing the keys. A sidecar caches the response and refreshes on its own schedule, on the order of 5 minutes. During rotation, there's a window where a sidecar still holds the old key set. More on that below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Rotation
&lt;/h2&gt;

&lt;p&gt;Rotation happens in two steps: create a new key version, then invalidate old versions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# Step 1: Rotate — creates a new key version, old versions remain valid&lt;/span&gt;
vault write &lt;span class="nt"&gt;-f&lt;/span&gt; transit/keys/mesh-identity/rotate

&lt;span class="c"&gt;# Step 2: Invalidate — bumps min_decryption_version so old keys can't verify&lt;/span&gt;
vault write transit/keys/mesh-identity/config &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;min_decryption_version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$NEW_LATEST&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--grace&lt;/code&gt; flag in the rotation script introduces a 300-second (5-minute) wait between these steps. Why 5 minutes? Because mesh tokens have a 5-minute TTL. After one full TTL passes, all tokens signed with the old key have expired. No valid old-key tokens remain in flight, so invalidating the old key causes no disruption.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"--grace"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Grace period: waiting 300s for in-flight tokens to expire..."&lt;/span&gt;
    &lt;span class="nb"&gt;sleep &lt;/span&gt;300
&lt;span class="k"&gt;fi
&lt;/span&gt;vault write transit/keys/mesh-identity/config &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;min_decryption_version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$NEW_LATEST&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bumping &lt;code&gt;min_decryption_version&lt;/code&gt; removes the old version from the JWKS endpoint, but sidecars don't pick that up instantly. Each one validates tokens against the JWKS it last fetched. A failed validation does not trigger an immediate refetch. The sidecar keeps using that cached key set until its next scheduled refresh, on the order of the 5-minute cache interval above.&lt;/p&gt;

&lt;p&gt;That cache lag is exactly why the grace period exists. By waiting one full 5-minute token TTL before bumping the minimum, every token signed with the old key has already expired. After that, it no longer matters whether a given sidecar has refreshed its JWKS. If it still has the old public key cached, there are no valid old-key tokens left to accept. If it has refreshed, it only sees the new key. Skip the grace period and you risk rejecting still-valid old-key tokens the moment a sidecar refreshes mid-flight. Either way, bumping the minimum only &lt;em&gt;archives&lt;/em&gt; a version; it stays recoverable by lowering &lt;code&gt;min_decryption_version&lt;/code&gt; again, and is gone for good only once it drops below &lt;code&gt;min_available_version&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The grace period covers the old key. The new key has a separate window. Auth Service sets &lt;code&gt;kid&lt;/code&gt; to the latest version on every mint, so it starts issuing tokens signed with the new key the instant you rotate. A sidecar whose JWKS cache is still stale has no public key for that &lt;code&gt;kid&lt;/code&gt; yet, and rejects those freshly-minted tokens with a 403. So rotation should also trigger a JWKS cache invalidation, forcing every sidecar to refetch immediately and pick up the new public key before any new-key token reaches it. That closes the window so clients never see a 403.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxhinvg3zpvhjxsf90c0j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxhinvg3zpvhjxsf90c0j.png" alt="Key rotation timeline with two tracks: the old key drains safely across the 5-minute grace window before min_decryption_version is bumped, while newly minted new-key tokens can be rejected with 403 until sidecars refetch JWKS" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Vault Token Scoping
&lt;/h2&gt;

&lt;p&gt;In this POC, Auth Service holds a Vault token with exactly two capabilities:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;update&lt;/code&gt; on &lt;code&gt;transit/sign/mesh-identity&lt;/code&gt; (can sign)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;read&lt;/code&gt; on &lt;code&gt;transit/keys/mesh-identity&lt;/code&gt; (can read public keys and current version)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It cannot rotate keys, cannot modify key configuration, cannot access any other Vault path. This is the minimum privilege needed for signing and JWKS serving.&lt;/p&gt;

&lt;p&gt;The token has a 32-day renewable period. Rotation of the Vault token itself (revoking and reissuing) is a separate operational concern from key rotation. In the production model described earlier, this same policy would be attached to the Vault token issued through Kubernetes Auth and managed by Vault Agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Blast Radius: What Happens When Things Break
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Auth Service compromised:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The attacker has the pod's signing authority: the direct Vault token in this POC, or access to the Agent-mediated signing path in the production model. They can sign arbitrary payloads under &lt;code&gt;mesh-identity&lt;/code&gt; and read public keys. They can mint tokens for any user, any role, with any audience. That breaks the user-identity layer for any service that accepts &lt;code&gt;x-mesh-identity&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It does not automatically bypass every mesh policy. Downstream &lt;code&gt;AuthorizationPolicy&lt;/code&gt; rules still check the caller's mTLS workload identity, and the token still has to carry the expected &lt;code&gt;aud&lt;/code&gt; (audience: which services may accept the token) and &lt;code&gt;act&lt;/code&gt; (the delegation claim naming the authorized intermediary) claims for that route. A forged token from the wrong pod can still fail the source-principal check. The real danger is that the compromised Auth Service can mint tokens that look legitimate to the JWT layer, including tokens with audiences and delegation claims chosen by the attacker.&lt;/p&gt;

&lt;p&gt;But the damage is bounded. The attacker cannot rotate keys or modify Vault configuration. You revoke the direct Vault token, or revoke the Agent-issued token and cut off the Kubernetes Auth path for that workload, and their signing ability stops. In-flight tokens they've already minted expire within 5 minutes. You rotate the key and bump &lt;code&gt;min_decryption_version&lt;/code&gt;, which drops the compromised version from the JWKS endpoint so sidecars stop accepting anything signed with it once they refresh their cache.&lt;/p&gt;

&lt;p&gt;Detection: Vault audit logs show every signing request with the token accessor. Anomalous patterns (sudden spike in signing, unusual audiences) are detectable if audit logging is enabled.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vault compromised:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the worst case, but the severity depends on the compromise depth. If the attacker gains root token access or breaks into the Vault API, they can sign arbitrary tokens through Transit. There's still an audit trail (Vault audit log records each API call), and you recover by rotating the Transit key and revoking the compromised token.&lt;/p&gt;

&lt;p&gt;If the attacker extracts the actual key material (storage backend compromise, memory dump, unsealed Vault snapshot), they can sign tokens offline, from anywhere, without making Vault API calls. No audit trail. Tokens they produce are indistinguishable from legitimate ones until you rotate to an entirely new key.&lt;/p&gt;

&lt;p&gt;In this POC, Vault uses &lt;code&gt;emptyDir&lt;/code&gt; storage (data lives only in the pod's ephemeral filesystem). In production, Vault would use encrypted backend storage with auto-unseal via a cloud KMS. The private key's residency model depends entirely on how Vault is deployed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A downstream service compromised (Employee Records, Device Inventory, etc.):&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The attacker can read projected headers for any request that arrives at that service. They see user IDs and roles for requests they handle. They cannot mint new mesh tokens (no Vault access) and cannot call other internal-only (Tier 2) services (AuthorizationPolicy blocks it).&lt;/p&gt;

&lt;p&gt;For database access, the compromised service has its scoped DB role (Employee Records can access &lt;code&gt;hr.*&lt;/code&gt; tables, Device Inventory can access &lt;code&gt;it.*&lt;/code&gt;). A malicious process could call &lt;code&gt;set_config('app.current_user_id', ...)&lt;/code&gt; with an arbitrary value to bypass row-level security (RLS) within its own schema. RLS stops application bugs, not a fully malicious process controlling its own DB session. The containment here is schema isolation (Employee Records can't query &lt;code&gt;it.*&lt;/code&gt; tables) and AuthorizationPolicy (Employee Records can't reach Device Inventory's database path).&lt;/p&gt;

&lt;h2&gt;
  
  
  The Anti-Patterns
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Local key pairs:&lt;/strong&gt; Auth Service generates and holds an RSA key pair. Compromise means the attacker has the key forever (until you detect and rotate). No audit trail of signing operations. No centralized rotation procedure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shared HMAC secrets:&lt;/strong&gt; All services share a symmetric key for signing/verification. Any compromised service can mint tokens. The blast radius of a single pod compromise becomes total. Rotation requires coordinated rollout to every service simultaneously.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Secrets in environment variables:&lt;/strong&gt; The key material is readable by any process in the container (via &lt;code&gt;/proc/self/environ&lt;/code&gt;), visible in crash dumps, and exposed through the container runtime debug surface. Even &lt;code&gt;secretKeyRef&lt;/code&gt; values end up as plaintext in the process environment once the pod starts. This is the Kubernetes default for "sensitive config."&lt;/p&gt;

&lt;p&gt;Each of these works fine at small scale with a trusted team. They become unacceptable when the question shifts from "can someone on our team see this secret" to "what happens when one pod in a 50-service mesh is compromised."&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;The next post covers authorization in depth: how network-level, request-level, resource-level, and field-level authorization compose into a system where each layer catches what the one above it misses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/Kaustav-Sarkar/Istio-Defense-In-Depth" rel="noopener noreferrer"&gt;https://github.com/Kaustav-Sarkar/Istio-Defense-In-Depth&lt;/a&gt;&lt;/p&gt;

</description>
      <category>vault</category>
      <category>istio</category>
      <category>kubernetes</category>
      <category>encryption</category>
    </item>
    <item>
      <title>The Identity Flow: From Gateway to Database</title>
      <dc:creator>Kaustav Sarkar</dc:creator>
      <pubDate>Tue, 01 Sep 2026 18:55:31 +0000</pubDate>
      <link>https://dev.to/curioustechie/the-identity-flow-from-gateway-to-database-4eb4</link>
      <guid>https://dev.to/curioustechie/the-identity-flow-from-gateway-to-database-4eb4</guid>
      <description>&lt;p&gt;This blog is a part of the Zero-Trust Security on Istio Series, if you haven't read the previous article, please do, it will provide better context.&lt;/p&gt;

&lt;p&gt;The previous post laid out the five security layers as a static architecture. This post follows a single request through them, down to the question that matters in practice: what does a service actually trust, and why is trusting it safe?&lt;/p&gt;

&lt;p&gt;The system is an HR employee directory, built to be attacked. Five microservices sit behind the mesh, numbered &lt;code&gt;ms1&lt;/code&gt; through &lt;code&gt;ms5&lt;/code&gt;: Profile Aggregator (&lt;code&gt;ms1&lt;/code&gt;) is the gateway-facing orchestrator; Employee Records (&lt;code&gt;ms2&lt;/code&gt;) holds PII and salary data and Device Inventory (&lt;code&gt;ms3&lt;/code&gt;) holds hardware assignments, both internal-only; Holiday Calendar (&lt;code&gt;ms4&lt;/code&gt;) and Office Directory (&lt;code&gt;ms5&lt;/code&gt;) serve holiday and office-location data, most of it public. Auth Service mints a signed identity token, &lt;code&gt;x-mesh-identity&lt;/code&gt;, that carries the user through the mesh, and Vault signs it. This post traces one concrete request: an employee, Alice, requests her own profile with &lt;code&gt;GET /api/profile/{id}&lt;/code&gt;. That request reaches Profile Aggregator, which fans out to Employee Records and Device Inventory. I'll follow the &lt;code&gt;ms1&lt;/code&gt; to &lt;code&gt;ms2&lt;/code&gt; hop, Profile Aggregator calling Employee Records, so every &lt;code&gt;ms2-*&lt;/code&gt; resource below is that running example, not a special case.&lt;/p&gt;

&lt;p&gt;Employee Records reads the user's identity from a plain HTTP header, eg &lt;code&gt;x-ms2-user&lt;/code&gt;. The header is named per service (&lt;code&gt;ms2&lt;/code&gt; reads &lt;code&gt;x-ms2-user&lt;/code&gt;, &lt;code&gt;ms3&lt;/code&gt; reads &lt;code&gt;x-ms3-user&lt;/code&gt;) so one service can never consume another's identity headers. Employee Records does no JWT parsing and makes no callback to an auth service. It reads that header and treats the value as the authenticated user. That should look fragile, because headers are trivially spoofable. Any external client can send &lt;code&gt;x-ms2-user: alice&lt;/code&gt;, and so can any compromised pod inside the cluster.&lt;/p&gt;

&lt;p&gt;It works because of everything that happens before a service reads that header. A single request moves through eight steps from the gateway to the database, each adding one guarantee:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Header stripping&lt;/strong&gt; at the gateway.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Authentication&lt;/strong&gt; of the caller's credentials.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Route-level authorization&lt;/strong&gt; against a coarse policy map.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Token minting&lt;/strong&gt;, signed via Vault.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Token validation and header projection&lt;/strong&gt; at the destination sidecar.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;AuthorizationPolicy&lt;/strong&gt;, binding peer identity to token claims.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The service contract&lt;/strong&gt;, the two headers the application actually reads.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Database identity context&lt;/strong&gt; for row-level enforcement.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Six of those steps run in the mesh before the application sees the request. By the time Employee Records reads &lt;code&gt;x-ms2-user&lt;/code&gt;, its value has been stripped of anything the caller sent and re-derived from the signed token the destination sidecar verified. The last two steps are the service's own contract and the database scoping it then sets up.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7nfl226lwl8u04j7gf21.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7nfl226lwl8u04j7gf21.png" alt="The identity flow: eight checkpoints from the Istio gateway to the PostgreSQL row-level security query" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trust Boundary
&lt;/h2&gt;

&lt;p&gt;The Istio Ingress Gateway is where external input is converted into internal identity. Everything before it is untrusted. An external client can send any header it wants, including headers that look exactly like internal identity assertions. The gateway's job is to strip those, authenticate the caller, and produce a cryptographically signed token that downstream services can verify independently.&lt;/p&gt;

&lt;p&gt;After the gateway, a service trusts only the headers its own destination sidecar wrote. The sidecar does the verification itself: it strips any inbound copy of the projected headers (like &lt;code&gt;x-ms2-user&lt;/code&gt;), validates the JWT signature, checks the issuer, and verifies expiry, then writes the claims into fresh headers. The application reads those headers directly and runs no verification of its own, because the sidecar already ran it.&lt;/p&gt;

&lt;p&gt;That trust is narrowly scoped. It covers only the headers the sidecar itself wrote after validating the token. Anything that merely arrived on the request, from an external client or an internal pod, is ignored. So if a compromised internal pod sends a spoofed &lt;code&gt;x-ms2-user&lt;/code&gt; header, the destination sidecar strips it before the JWT filter runs and re-projects the value from the verified token. The spoofed header never reaches the application.&lt;/p&gt;

&lt;p&gt;This model has a clear contract: the gateway is responsible for identity creation, sidecars are responsible for identity verification, and services are responsible for business logic. If any of those three fail in isolation, the other two still limit the blast radius.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Header Stripping
&lt;/h2&gt;

&lt;p&gt;Before authentication, before routing, before anything else, a Lua EnvoyFilter removes every internal trust header from the incoming request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.istio.io/v1alpha3&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;EnvoyFilter&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway-prestrip&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;istio-system&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;-100&lt;/span&gt;
  &lt;span class="na"&gt;workloadSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;istio&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ingressgateway&lt;/span&gt;
  &lt;span class="na"&gt;configPatches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;applyTo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTP_FILTER&lt;/span&gt;
      &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GATEWAY&lt;/span&gt;
        &lt;span class="na"&gt;listener&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;filterChain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;envoy.filters.network.http_connection_manager"&lt;/span&gt;
              &lt;span class="na"&gt;subFilter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;envoy.filters.http.ext_authz"&lt;/span&gt;
      &lt;span class="na"&gt;patch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;operation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;INSERT_BEFORE&lt;/span&gt;
        &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;envoy.filters.http.lua.prestrip&lt;/span&gt;
          &lt;span class="na"&gt;typed_config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@type"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua&lt;/span&gt;
            &lt;span class="s"&gt;inlineCode&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
              &lt;span class="s"&gt;function envoy_on_request(request_handle)&lt;/span&gt;
                &lt;span class="s"&gt;local headers_to_remove = {}&lt;/span&gt;
                &lt;span class="s"&gt;for key, value in pairs(request_handle:headers()) do&lt;/span&gt;
                  &lt;span class="s"&gt;local lower_key = string.lower(key)&lt;/span&gt;
                  &lt;span class="s"&gt;if string.match(lower_key, "^x%-platform%-") or&lt;/span&gt;
                     &lt;span class="s"&gt;string.match(lower_key, "^x%-role%-") or&lt;/span&gt;
                     &lt;span class="s"&gt;lower_key == "x-mesh-identity" or&lt;/span&gt;
                     &lt;span class="s"&gt;string.match(lower_key, "^x%-ms%d%-user") or&lt;/span&gt;
                     &lt;span class="s"&gt;string.match(lower_key, "^x%-ms%d%-role") then&lt;/span&gt;
                    &lt;span class="s"&gt;table.insert(headers_to_remove, lower_key)&lt;/span&gt;
                  &lt;span class="s"&gt;end&lt;/span&gt;
                &lt;span class="s"&gt;end&lt;/span&gt;
                &lt;span class="s"&gt;for _, key in ipairs(headers_to_remove) do&lt;/span&gt;
                  &lt;span class="s"&gt;request_handle:headers():remove(key)&lt;/span&gt;
                &lt;span class="s"&gt;end&lt;/span&gt;
              &lt;span class="s"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The filter runs at priority &lt;code&gt;-100&lt;/code&gt; with &lt;code&gt;INSERT_BEFORE&lt;/code&gt; the ExtAuthz filter. The order is load-bearing: if ExtAuthz ran first and injected &lt;code&gt;x-mesh-identity&lt;/code&gt; based on a valid session, but a spoofed &lt;code&gt;x-ms2-user&lt;/code&gt; header also survived from the client, the downstream service would read the attacker-controlled value.&lt;/p&gt;

&lt;p&gt;The two-pass approach (collect keys, then remove) is required because mutating headers during iteration is undefined behavior in Envoy's Lua API.&lt;/p&gt;

&lt;p&gt;What gets stripped: &lt;code&gt;x-platform-*&lt;/code&gt;, &lt;code&gt;x-role-*&lt;/code&gt;, &lt;code&gt;x-mesh-identity&lt;/code&gt;, and all &lt;code&gt;x-ms&amp;lt;N&amp;gt;-user&lt;/code&gt; / &lt;code&gt;x-ms&amp;lt;N&amp;gt;-role&lt;/code&gt; headers. Case-insensitive matching ensures no variant slips through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Authentication (ExtAuthz)
&lt;/h2&gt;

&lt;p&gt;After stripping, the request hits the ExtAuthz filter. This calls Auth Service (&lt;code&gt;auth-service&lt;/code&gt;), which resolves the caller's identity from one of two sources:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bearer token:&lt;/strong&gt; Extracted from the &lt;code&gt;Authorization&lt;/code&gt; header, validated against Keycloak's JWKS endpoint. Roles come from &lt;code&gt;realm_access.roles&lt;/code&gt; in the Keycloak JWT.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Session cookie:&lt;/strong&gt; An opaque &lt;code&gt;zt_session&lt;/code&gt; cookie, looked up in PostgreSQL. The session record holds the user's roles from the original OIDC flow.&lt;/p&gt;

&lt;p&gt;Both paths produce the same output: a subject identifier, username, email, roles, groups, and department. The subject is deterministic across both auth paths. It's computed as &lt;code&gt;uuid5(NAMESPACE_URL, "istio-security://users/&amp;lt;username&amp;gt;")&lt;/code&gt;, which means the same user always gets the same &lt;code&gt;sub&lt;/code&gt; regardless of whether they authenticated via cookie or bearer token. This is important for RLS (which filters by &lt;code&gt;sub&lt;/code&gt;) and for audit trails.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Route-Level Authorization
&lt;/h2&gt;

&lt;p&gt;Before minting a token, Auth Service checks a coarse policy map. This is the first authorization gate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Coarse route/role policy map (ext_authz.py)
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/profile/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;allowed_roles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;employee&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;manager&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hr_admin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;it_admin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audience&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ms1-profile-aggregator&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ms2-employee-details&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ms3-hardware-assets&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/holidays&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;allowed_roles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;employee&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;manager&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hr_admin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;it_admin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;public_data_admin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;security_auditor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audience&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ms4-holiday-calendar&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/holidays&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PUT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DELETE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PATCH&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;allowed_roles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;public_data_admin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hr_admin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audience&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ms4-holiday-calendar&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Route not defined in coarse policy map&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;

&lt;span class="n"&gt;user_roles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;roles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;allowed_roles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;intersection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_roles&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Unauthorized role for route&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the user's role doesn't match the route, the request is denied with 401. No token is minted. The &lt;code&gt;audience&lt;/code&gt; field determines which services this token will be valid for. A token for the profile endpoint is valid at Profile Aggregator (&lt;code&gt;ms1-profile-aggregator&lt;/code&gt;), Employee Records (&lt;code&gt;ms2-employee-details&lt;/code&gt;), and Device Inventory (&lt;code&gt;ms3-hardware-assets&lt;/code&gt;). A token for the holidays endpoint is valid only at Holiday Calendar (&lt;code&gt;ms4-holiday-calendar&lt;/code&gt;). This prevents a valid token from one flow being replayed against a different service.&lt;/p&gt;

&lt;p&gt;Any path not in the policy map is denied by default. &lt;code&gt;GET /api/offices&lt;/code&gt; is deliberately absent, because those reads are public: the gateway's &lt;code&gt;AuthorizationPolicy&lt;/code&gt; exempts them from ExtAuthz, so an unauthenticated read reaches Office Directory (&lt;code&gt;ms5-office-locations&lt;/code&gt;) without a token and is treated as &lt;code&gt;anonymous/public&lt;/code&gt;. Writes to &lt;code&gt;/api/offices&lt;/code&gt; do go through the map above and require &lt;code&gt;public_data_admin&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Token Minting
&lt;/h2&gt;

&lt;p&gt;If authentication and route authorization both pass, Auth Service mints a signed mesh token. The JWT is built manually (no library) and signed via Vault Transit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RS256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;typ&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JWT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vault_key_version&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;

&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iss&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auth-service&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;subject_uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;preferred_username&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;roles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;employee&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;manager&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;roles_csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;employee,manager&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aud&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ms1-profile-aggregator&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ms2-employee-details&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ms3-hardware-assets&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;act&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ms1-profile-aggregator&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# 5 minutes
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jti&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;request_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;request_id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The signing input (&lt;code&gt;header_b64.payload_b64&lt;/code&gt;) is sent to Vault Transit's &lt;code&gt;/v1/transit/sign/mesh-identity&lt;/code&gt; endpoint. Vault signs it with the RSA-2048 private key and returns the signature. Auth Service never holds the private key.&lt;/p&gt;

&lt;p&gt;Key details:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;roles_csv&lt;/code&gt; exists because Istio's &lt;code&gt;outputClaimToHeaders&lt;/code&gt; can only project string claims, not arrays.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;act&lt;/code&gt; (the delegation claim from RFC 8693) is set to &lt;code&gt;ms1-profile-aggregator&lt;/code&gt; when Profile Aggregator is in the audience. Auth Service sets this at mint time because it already knows (from the route-policy map) that the profile flow will pass through Profile Aggregator to reach Employee Records and Device Inventory. The &lt;code&gt;act&lt;/code&gt; claim asserts that Profile Aggregator is the authorized intermediary for this request. When Profile Aggregator forwards the token downstream, the receiving AuthorizationPolicy verifies both the mTLS identity (is this actually Profile Aggregator?) and the &lt;code&gt;act&lt;/code&gt; claim (was Profile Aggregator designated as the intermediary?). If a different service somehow obtained this token, the mTLS check would fail.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;kid&lt;/code&gt; is fetched live from Vault on every mint. When keys rotate, new tokens immediately reference the new version.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;TTL is 5 minutes. Short enough to limit replay windows, long enough that a fan-out from Profile Aggregator to Employee Records and Device Inventory completes comfortably.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The JWT is assembled manually rather than via a library like PyJWT. This is because the signing step goes through Vault Transit's API (not a local key), and standard JWT libraries assume they hold the private key. Building the token by hand keeps the signing interface clean: encode header and payload, send bytes to Vault, attach the returned signature.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 5: Token Validation and Header Projection
&lt;/h2&gt;

&lt;p&gt;The signed token travels as the &lt;code&gt;x-mesh-identity&lt;/code&gt; header. The destination sidecar handles it in two stages before the application sees the request.&lt;/p&gt;

&lt;p&gt;First, a Lua EnvoyFilter strips any inbound &lt;code&gt;x-ms2-user&lt;/code&gt; / &lt;code&gt;x-ms2-role&lt;/code&gt; headers. It runs at &lt;code&gt;priority: 10&lt;/code&gt; with &lt;code&gt;INSERT_BEFORE&lt;/code&gt; the JWT authentication filter, so spoofed values are removed before any claim is projected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.istio.io/v1alpha3&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;EnvoyFilter&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;header-projection-ms2&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;zt-apps&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
  &lt;span class="na"&gt;workloadSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ms2-employee-details&lt;/span&gt;
  &lt;span class="na"&gt;configPatches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;applyTo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTP_FILTER&lt;/span&gt;
      &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SIDECAR_INBOUND&lt;/span&gt;
        &lt;span class="na"&gt;listener&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;filterChain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;envoy.filters.network.http_connection_manager"&lt;/span&gt;
              &lt;span class="na"&gt;subFilter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;envoy.filters.http.jwt_authn"&lt;/span&gt;
      &lt;span class="na"&gt;patch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;operation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;INSERT_BEFORE&lt;/span&gt;
        &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;envoy.filters.http.lua.projection&lt;/span&gt;
          &lt;span class="na"&gt;typed_config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@type"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua&lt;/span&gt;
            &lt;span class="s"&gt;inlineCode&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
              &lt;span class="s"&gt;function envoy_on_request(request_handle)&lt;/span&gt;
                &lt;span class="s"&gt;request_handle:headers():remove("x-ms2-user")&lt;/span&gt;
                &lt;span class="s"&gt;request_handle:headers():remove("x-ms2-role")&lt;/span&gt;
              &lt;span class="s"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the destination-side counterpart to the gateway pre-strip described in &lt;em&gt;The Trust Boundary&lt;/em&gt;: the same strip-then-project pattern, now enforced at the workload. Any inbound copy of these headers is removed before the JWT filter runs.&lt;/p&gt;

&lt;p&gt;Then the &lt;code&gt;RequestAuthentication&lt;/code&gt; resource validates the token and projects the verified claims:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;security.istio.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RequestAuthentication&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mesh-identity-ms2&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;zt-apps&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ms2-employee-details&lt;/span&gt;
  &lt;span class="na"&gt;jwtRules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;issuer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auth-service"&lt;/span&gt;
      &lt;span class="na"&gt;jwksUri&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://auth-service.zt-apps.svc.cluster.local:8000/auth/jwks"&lt;/span&gt;
      &lt;span class="na"&gt;forwardOriginalToken&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
      &lt;span class="na"&gt;fromHeaders&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x-mesh-identity"&lt;/span&gt;
      &lt;span class="na"&gt;outputClaimToHeaders&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;header&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x-ms2-user"&lt;/span&gt;
          &lt;span class="na"&gt;claim&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sub"&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;header&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x-ms2-role"&lt;/span&gt;
          &lt;span class="na"&gt;claim&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;roles_csv"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sidecar fetches public keys from the Auth Service JWKS endpoint, validates the JWT signature, checks &lt;code&gt;iss&lt;/code&gt; and &lt;code&gt;exp&lt;/code&gt;, and then projects &lt;code&gt;sub&lt;/code&gt; into &lt;code&gt;x-ms2-user&lt;/code&gt; and &lt;code&gt;roles_csv&lt;/code&gt; into &lt;code&gt;x-ms2-role&lt;/code&gt;. Because the strip filter already cleared any inbound copies, these headers can only carry claims from a validated token. These are the only headers the application reads.&lt;/p&gt;

&lt;p&gt;One Istio nuance is easy to miss: &lt;code&gt;RequestAuthentication&lt;/code&gt; validates a JWT when one is present. It does not, by itself, mean "a JWT is required." The requirement comes from the &lt;code&gt;AuthorizationPolicy&lt;/code&gt; in the next step. That policy checks claims that only exist after successful validation, so a missing or invalid token has no matching rule and is denied.&lt;/p&gt;

&lt;p&gt;Profile Aggregator has &lt;code&gt;forwardOriginalToken: true&lt;/code&gt; because it needs to propagate &lt;code&gt;x-mesh-identity&lt;/code&gt; to Employee Records and Device Inventory. All other services have it set to &lt;code&gt;false&lt;/code&gt; since they're terminal and don't make downstream calls that need identity propagation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: AuthorizationPolicy
&lt;/h2&gt;

&lt;p&gt;After token validation, Istio's &lt;code&gt;AuthorizationPolicy&lt;/code&gt; enforces token presence, token scope, and peer identity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;security.istio.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AuthorizationPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;allow-ms1-to-ms2&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;zt-apps&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ms2-employee-details&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ALLOW&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;principals&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;cluster.local/ns/zt-apps/sa/ms1-profile-aggregator-sa&lt;/span&gt;
      &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;operation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8000"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;request.auth.claims[aud]&lt;/span&gt;
          &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ms2-employee-details"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;request.auth.claims[act][sub]&lt;/span&gt;
          &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ms1-profile-aggregator"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things must be true simultaneously: the request must come from &lt;code&gt;ms1-profile-aggregator-sa&lt;/code&gt; over mTLS, the token must be valid for &lt;code&gt;ms2-employee-details&lt;/code&gt;, and the token must name &lt;code&gt;ms1-profile-aggregator&lt;/code&gt; as the authorized intermediary. The peer identity comes from the client certificate. The &lt;code&gt;aud&lt;/code&gt; and &lt;code&gt;act&lt;/code&gt; values come from the signed JWT. These are independent cryptographic assertions. Compromising one doesn't give you the other.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;aud&lt;/code&gt; claim is a list because a single profile request legitimately reaches more than one service. Istio's claim matching checks whether the configured value is present in that claim, so a token minted for Profile Aggregator, Employee Records, and Device Inventory can match &lt;code&gt;ms2-employee-details&lt;/code&gt; here. A token minted only for Holiday Calendar would not match.&lt;/p&gt;

&lt;p&gt;This is confused-deputy prevention: stopping a privileged intermediary from being tricked into using its authority on behalf of an attacker. If Profile Aggregator's service account is somehow used by a different process, the process would still need a validly-signed token that authorizes Profile Aggregator to call Employee Records for this user flow. If an attacker replays a legitimate token from a different pod, the mTLS identity check fails. Both bindings must match.&lt;/p&gt;

&lt;p&gt;The deny behavior depends on having an &lt;code&gt;AuthorizationPolicy&lt;/code&gt; that targets the workload. Istio allows traffic by default when no policy applies. In this architecture, every protected workload has at least one scoped &lt;code&gt;ALLOW&lt;/code&gt; policy, so unmatched traffic is denied. A request with no token, a bad signature, the wrong audience, or the wrong caller identity has no matching rule and never reaches the application.&lt;/p&gt;

&lt;p&gt;Fine-grained authorization (can this user see &lt;em&gt;this specific record&lt;/em&gt;, and which fields?) also runs at this point, via Cerbos. That's the resource and field-level layer, covered in a later post. This post stays on the identity flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: The Service Contract
&lt;/h2&gt;

&lt;p&gt;After all of this, what does the service actually see? Two headers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_ms2_headers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;x_ms2_user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;x_ms2_role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;x_request_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;x_ms2_user&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;x_ms2_role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Missing required legacy headers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;x_ms2_user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;x_ms2_role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;request_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;x_request_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The service reads &lt;code&gt;x-ms2-user&lt;/code&gt; and &lt;code&gt;x-ms2-role&lt;/code&gt;. It returns 401 if they're missing. That's the entire identity contract. The 401 message calls them "legacy" headers, but the name only refers to this plain-header interface: the service reads simple headers as if it had no awareness of the mesh, and the mesh does the real verification upstream. The service does no JWT parsing, OIDC configuration, or Vault calls of its own. It trusts that if these headers are present, the sidecar has already done the verification.&lt;/p&gt;

&lt;p&gt;Profile Aggregator forwards &lt;code&gt;x-mesh-identity&lt;/code&gt; and &lt;code&gt;x-request-id&lt;/code&gt; to downstream services. It explicitly does not forward &lt;code&gt;x-ms2-user&lt;/code&gt; or &lt;code&gt;x-ms2-role&lt;/code&gt; since the receiving sidecar projects those independently from the JWT. Profile Aggregator cannot influence what identity Employee Records sees.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 8: Database Identity Context
&lt;/h2&gt;

&lt;p&gt;The final step. Before executing any query, the service sets the transaction context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;set_rls_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT set_config(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;app.current_user_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, :user_id, true)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT set_config(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;app.current_roles&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, :roles, true)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;roles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT set_config(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;app.request_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, :request_id, true)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;request_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PostgreSQL RLS policies read &lt;code&gt;app.current_user_id&lt;/code&gt; and &lt;code&gt;app.current_roles&lt;/code&gt; to filter rows; &lt;code&gt;app.request_id&lt;/code&gt; is set for audit correlation. The &lt;code&gt;true&lt;/code&gt; third argument makes each setting transaction-local, so it doesn't leak across pooled connections.&lt;/p&gt;

&lt;p&gt;At this point, the database sees the same user identity that the sidecar projected from the verified token. In normal operation, the service is a pass-through for identity. It reads the sidecar-projected header and sets it on the transaction.&lt;/p&gt;

&lt;p&gt;A fully compromised service process could call &lt;code&gt;set_config&lt;/code&gt; with an arbitrary value. RLS doesn't protect against a malicious process controlling its own DB session. But the layers above constrain this: the compromised service only has its own scoped DB role (Employee Records can only access &lt;code&gt;hr.*&lt;/code&gt; tables, Device Inventory only &lt;code&gt;it.*&lt;/code&gt;), and AuthorizationPolicy prevents it from reaching other services' databases. The blast radius of a compromised Employee Records service is limited to the HR data it already has access to, scoped by RLS to the identities it actually receives requests for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure Behavior
&lt;/h2&gt;

&lt;p&gt;The secure path matters, but so does the failure path. A missing or invalid mesh token fails at &lt;code&gt;AuthorizationPolicy&lt;/code&gt; because the required claims are absent. If Auth Service is down, ExtAuthz cannot approve the request and no mesh token is minted. If Vault is unreachable, Auth Service cannot sign a token. If the sidecar cannot refresh JWKS, it continues using its cached keys until the cache expires; after that, validation fails closed for tokens it cannot verify.&lt;/p&gt;

&lt;p&gt;The one operational caveat is key rotation. Istio sidecars cache JWKS, so there is a short window where a sidecar may still verify tokens with a cached old public key. The next post covers that rotation window and why the token TTL is only 5 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Complete Chain
&lt;/h2&gt;

&lt;p&gt;Gateway strips external headers -&amp;gt; Auth Service validates credentials -&amp;gt; Auth Service checks route-level policy -&amp;gt; Auth Service mints a signed token -&amp;gt; sidecar validates the token and projects claims -&amp;gt; AuthorizationPolicy checks peer identity and token claims -&amp;gt; service reads projected headers -&amp;gt; database enforces row-level visibility.&lt;/p&gt;

&lt;p&gt;Eight steps. By the time Employee Records reads &lt;code&gt;x-ms2-user&lt;/code&gt;, that value has passed through header stripping, credential validation, route authorization, token signing, sidecar validation, and peer binding. The service receives a narrow identity contract and passes that identity into PostgreSQL for row-level enforcement.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;The next post covers the signing infrastructure: why Vault Transit instead of local keys, how key rotation works without downtime, and what the blast radius looks like when different components are compromised.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/Kaustav-Sarkar/Istio-Defense-In-Depth" rel="noopener noreferrer"&gt;https://github.com/Kaustav-Sarkar/Istio-Defense-In-Depth&lt;/a&gt;&lt;/p&gt;

</description>
      <category>istio</category>
      <category>authentication</category>
      <category>postgres</category>
      <category>cybersecurity</category>
    </item>
    <item>
      <title>Service Mesh Zero-Trust Architecture</title>
      <dc:creator>Kaustav Sarkar</dc:creator>
      <pubDate>Tue, 01 Sep 2026 18:37:58 +0000</pubDate>
      <link>https://dev.to/curioustechie/service-mesh-zero-trust-architecture-52o4</link>
      <guid>https://dev.to/curioustechie/service-mesh-zero-trust-architecture-52o4</guid>
      <description>&lt;p&gt;Zero-trust is a property that emerges when several independent checks each verify a request on their own, without assuming the layers around them already did the work. It often gets described as a product you install or a setting you switch on, which badly undersells how much has to line up for it to actually hold.&lt;/p&gt;

&lt;p&gt;I learned that building an HR directory designed to be attacked. The rule I wanted it to hold was simple: if any single pod gets compromised, it still can't read data it has no business touching. Salaries, bank details, device serial numbers, the things an attacker would actually go after.&lt;/p&gt;

&lt;p&gt;No single mechanism enforces that rule. mTLS proves which service is on the other end of a connection, but it has nothing to say about whether that service should be making the call. A signed identity token establishes who the user is, yet a service that trusts raw headers will believe whatever an attacker sends it. Authorization decides what a user is allowed to do, and then an application bug skips the check and the database returns the whole table anyway. Every layer has a blind spot, and the thing that covers it is another layer that doesn't share the same one.&lt;/p&gt;

&lt;p&gt;So the architecture is a stack: every hop independently verified, every request authenticated and authorized, each layer built on the assumption that the one before it might fail. Remove a single layer and the others are left exposed in the exact spots they were relying on it to cover.&lt;/p&gt;

&lt;p&gt;This post is the architecture overview for the series: the application, the five security layers, how they compose into a single request flow, and what specifically breaks when you remove one. It all runs end-to-end on Istio on a local Kind cluster with real config. Later posts go deep into the YAML, Python, and policy files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you need to know going in&lt;/strong&gt;: Istio is a service mesh for Kubernetes. The key idea is that every pod gets an Envoy sidecar proxy. All inbound and outbound traffic passes through the sidecar, which means identity verification, routing rules, and policy enforcement happen at the infrastructure layer before your application code sees the request. If you understand containers and basic networking, the rest will make sense as we go.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Application
&lt;/h2&gt;

&lt;p&gt;The domain is deliberately simple: an HR Employee Directory. Five microservices, a few database tables, a handful of roles. The security architecture is the interesting part. In the demo there are 4 roles used.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Alice (employee)&lt;/strong&gt; — on her own profile she sees full PII and exact salary; everyone else only gets name, title, and department.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Mary (manager)&lt;/strong&gt; — on Alice (her direct report) she sees salary &lt;strong&gt;band&lt;/strong&gt;, not exact pay or SSN; on others, same public view as any employee.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Henry (HR admin)&lt;/strong&gt; — full HR fields on all employees (salary, SSN, etc.), but &lt;strong&gt;no&lt;/strong&gt; full IT asset serial numbers.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Ivan (IT admin)&lt;/strong&gt; — only basic employee fields everywhere, but &lt;strong&gt;full&lt;/strong&gt; hardware/asset details including serials.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flh92qimcv0fpuax58zoo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flh92qimcv0fpuax58zoo.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The services:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Profile Aggregator&lt;/strong&gt; (&lt;code&gt;ms1-profile-aggregator&lt;/code&gt;) is a fan-out orchestrator that calls Employee Records and Device Inventory, merges the results, and returns a single profile response. Gateway-facing, requires authentication.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Employee Records&lt;/strong&gt; (&lt;code&gt;ms2-employee-details&lt;/code&gt;) is the source of truth for employee records, PII, and financial data (salary, bank details). Internal only, not directly reachable from outside.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Device Inventory&lt;/strong&gt; (&lt;code&gt;ms3-hardware-assets&lt;/code&gt;) tracks which devices are assigned to which employees (serial numbers, MAC addresses). Internal only, same as Employee Records.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Holiday Calendar&lt;/strong&gt; (&lt;code&gt;ms4-holiday-calendar&lt;/code&gt;) handles company holidays. Gateway-facing, requires authentication. Read by everyone, written by HR.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Office Directory&lt;/strong&gt; (&lt;code&gt;ms5-office-locations&lt;/code&gt;) serves office location data. Gateway-facing, no authentication required for reads. The only fully public endpoint in the system.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Six roles : &lt;code&gt;employee&lt;/code&gt;, &lt;code&gt;manager&lt;/code&gt;, &lt;code&gt;hr_admin&lt;/code&gt;, &lt;code&gt;it_admin&lt;/code&gt;, &lt;code&gt;public_data_admin&lt;/code&gt;, and &lt;code&gt;security_auditor&lt;/code&gt;. The first four drive the HR/profile flow and are the focus of this series. &lt;code&gt;public_data_admin&lt;/code&gt; manages office and holiday data, and &lt;code&gt;security_auditor&lt;/code&gt; has read-only access for compliance. The point that matters for security: each role sees a different slice of data from the same endpoints, controlled entirely by policy.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqa2cil66h6csvr9i40sz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqa2cil66h6csvr9i40sz.png" alt=" " width="800" height="707"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The tiering matters for security:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Tier 1&lt;/strong&gt; (gateway-facing): Profile Aggregator, Holiday Calendar, Office Directory. Reachable from the Istio ingress gateway.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Tier 2&lt;/strong&gt; (internal only): Employee Records, Device Inventory. Reachable exclusively from Profile Aggregator. No direct external path exists.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Tier 3&lt;/strong&gt; (data): PostgreSQL. Reachable only from specific service identities.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Holiday Calendar and Office Directory cannot reach Employee Records or Device Inventory regardless of what code runs inside them. Profile Aggregator also cannot connect directly to PostgreSQL. The mesh intercepts every connection attempt and checks whether the calling service is allowed to talk to the destination. If it's not on the allow-list, the connection is rejected before any application code even sees the request.&lt;/p&gt;

&lt;p&gt;An attacker who compromises Holiday Calendar cannot reach Employee Records. The network topology itself is a security boundary, enforced by &lt;code&gt;Istio&lt;/code&gt; &lt;code&gt;AuthorizationPolicy&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Threat Model
&lt;/h2&gt;

&lt;p&gt;Every security layer exists because of a specific assumption about what can go wrong. These are the threats we're designing against:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;An external user may send arbitrary headers, including internal trust headers, in their HTTP request.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Any single application pod may be compromised and act maliciously within whatever network access it has.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Application code may have bugs: missing authorization checks, overly broad queries, endpoints that skip validation.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Database queries may return more data than intended if the application doesn't filter correctly.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Signing keys and secrets must not live inside application pods, because any compromised pod leaks whatever it holds.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each layer in the architecture addresses one or more of these. When we get to "What Happens When You Skip a Layer," you can map each failure directly back to this list.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Layers
&lt;/h2&gt;

&lt;p&gt;Five distinct security layers, each addressing an attack surface the others can't cover.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Transport Security (mTLS)
&lt;/h3&gt;

&lt;p&gt;Mutual TLS encrypts data in transit and verifies the identity of both ends of the connection. Every pod in the mesh presents a SPIFFE identity certificate, and the receiving pod validates it before accepting any bytes.&lt;/p&gt;

&lt;p&gt;This gives you encrypted communication and peer identity verification. A rogue service without a valid mesh certificate cannot establish a connection. But mTLS has a specific scope: it verifies that Service A &lt;em&gt;is&lt;/em&gt; Service A. It does not determine whether Service A is &lt;em&gt;allowed&lt;/em&gt; to call Service B with this particular payload for this particular user. That's a different problem entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Identity Propagation (Signed Mesh Tokens)
&lt;/h3&gt;

&lt;p&gt;Once a request enters the mesh and passes authentication at the gateway, the system needs to carry the user's verified identity through every downstream hop. A request from Alice hits Profile Aggregator, which then fans out to Employee Records and Device Inventory. Both downstream services need to know it's Alice, what role she holds, and that the request is legitimate.&lt;/p&gt;

&lt;p&gt;This is done via a cryptographically signed JWT, the &lt;code&gt;x-mesh-identity&lt;/code&gt; token, minted by Auth Service (&lt;code&gt;auth-service&lt;/code&gt;) and signed by Vault Transit. The token carries who the user is (&lt;code&gt;sub&lt;/code&gt;), what roles they hold (&lt;code&gt;roles_csv&lt;/code&gt;), which services this token is valid for (&lt;code&gt;aud&lt;/code&gt;), and who is acting on behalf of the user (&lt;code&gt;act&lt;/code&gt;, for service-to-service delegation).&lt;/p&gt;

&lt;p&gt;Any downstream service can verify this token using the public key from the Auth Service JWKS endpoint. No callback to Auth Service needed. But the token alone isn't sufficient. If projected headers like &lt;code&gt;x-ms2-user&lt;/code&gt; arrive from outside the mesh without being stripped, a service reading those headers trusts attacker-controlled values. That's why header stripping at the gateway is non-negotiable, and why each destination sidecar also strips and re-projects these headers from the verified JWT before the request reaches the application.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Request Authorization (Policy Enforcement)
&lt;/h3&gt;

&lt;p&gt;Knowing who someone is doesn't mean they're allowed to do what they're asking. Authorization in this architecture operates at four granularities, and each one catches what the layer above misses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Network-level:&lt;/strong&gt; Can this pod even reach that pod? Istio &lt;code&gt;AuthorizationPolicy&lt;/code&gt; enforces which service identities are allowed to initiate connections.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Request-level:&lt;/strong&gt; Is this user, with this role, allowed to call this endpoint? The &lt;code&gt;ExtAuthz&lt;/code&gt; check at the gateway applies coarse route-level decisions.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Resource-level:&lt;/strong&gt; Can this manager see &lt;em&gt;this specific employee's&lt;/em&gt; record? Cerbos evaluates fine-grained, context-aware policies.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Field-level:&lt;/strong&gt; Can this role see salary data, or should it be masked? Cerbos again, with policies that return which fields to include per role.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why four layers instead of one? Because a single policy engine can't efficiently handle all of these. Network-level happens at the sidecar before any application code runs. Field-level requires knowing the actual resource attributes at query time. Different layers, different information available, different enforcement points.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Data Access Security (Row-Level Security)
&lt;/h3&gt;

&lt;p&gt;The database is the last line of defense. PostgreSQL Row-Level Security policies enforce visibility at the engine level based on the current transaction context. Even if application code has a bug, a missing authorization check or a broken filter, RLS ensures the database itself drops rows the user shouldn't see.&lt;/p&gt;

&lt;p&gt;Application developers make mistakes. A query like &lt;code&gt;SELECT * FROM employees&lt;/code&gt; hitting production without a proper WHERE clause should still not leak the entire company directory. RLS makes that guarantee at the database layer, independent of whatever the application did or didn't check.&lt;/p&gt;

&lt;p&gt;There is a trust boundary here: the application sets &lt;code&gt;app.current_user_id&lt;/code&gt; on the transaction, and RLS uses that value. If the application sets it incorrectly, RLS enforces the wrong identity. In normal operation, the identity comes from sidecar-projected headers, so the application is a pass-through, not a source. But RLS does not protect against a fully compromised application process that sets arbitrary transaction context. RLS catches bugs (missing filters, broad queries). It does not stop a malicious process that controls its own DB session. The defense against that scenario is the layers above: AuthorizationPolicy limiting which pods can reach the database, scoped DB roles limiting which tables they can query, and Vault ensuring compromised pods can't escalate signing privileges.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Secrets Management (Vault)
&lt;/h3&gt;

&lt;p&gt;Secrets in pods are the most common attack surface in Kubernetes. Environment variables, mounted files, ConfigMaps. Assume any compromised pod leaks whatever secrets it holds.&lt;/p&gt;

&lt;p&gt;In this architecture, Vault serves a specific critical function: the Transit engine signs mesh identity tokens without ever exposing the private key to any service. Auth Service sends bytes to Vault, Vault signs them, returns the signature. The private key is never exposed to Auth Service or any application pod. If Auth Service is compromised, the attacker can ask Vault to sign things (until you revoke access), but they never get the key itself. They can't take it offline and mint tokens indefinitely.&lt;/p&gt;

&lt;p&gt;Beyond signing, Vault is the natural home for database credentials, API keys, TLS certificates, and anything that would otherwise be scattered across pod specs. The pattern matters more than our specific usage: services request secrets through controlled APIs rather than holding them in memory at rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Layers Compose
&lt;/h2&gt;

&lt;p&gt;These layers form a chain where each one depends on the guarantees of the ones around it. Here's what happens when a request enters the system.&lt;/p&gt;

&lt;p&gt;An external request hits the Istio Gateway. TLS terminates. Before anything else, an EnvoyFilter strips all internal trust headers (&lt;code&gt;x-mesh-identity&lt;/code&gt;, &lt;code&gt;x-ms*-user&lt;/code&gt;, &lt;code&gt;x-role-*&lt;/code&gt;), ensuring nothing spoofable survives from outside. This is the single most important security mechanism in the entire architecture. Without it, everything downstream can be bypassed by setting a header.&lt;/p&gt;

&lt;p&gt;The gateway's ExtAuthz filter calls Auth Service. Auth Service validates either the session cookie (opaque, database-backed) or the Bearer token (validated against Keycloak's JWKS). If valid, it calls Vault Transit to sign a short-lived mesh identity token with a 5 minute TTL. This token comes back to Envoy as a response header and gets injected into the request.&lt;/p&gt;

&lt;p&gt;Envoy routes the request to the target service. At the destination, a &lt;code&gt;RequestAuthentication&lt;/code&gt; resource validates the &lt;code&gt;x-mesh-identity&lt;/code&gt; token against the Auth Service JWKS endpoint. Istio's native &lt;code&gt;outputClaimToHeaders&lt;/code&gt; projects the validated claims into service-specific headers (&lt;code&gt;x-ms2-user&lt;/code&gt;, &lt;code&gt;x-ms2-role&lt;/code&gt;). The service never touches the JWT directly. It just reads simple headers that the sidecar guarantees are legitimate.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AuthorizationPolicy&lt;/code&gt; at the destination checks both the mTLS peer identity (is the calling pod's service account allowed?) and the token claims (is the audience correct? is the delegation chain valid?). A token minted for Employee Records cannot be replayed against Device Inventory because the AuthorizationPolicy checks the &lt;code&gt;aud&lt;/code&gt; claim. Both the source principal and the token audience must match for the request to proceed.&lt;/p&gt;

&lt;p&gt;The service calls Cerbos for fine-grained authorization. Can this user, with this role, perform this action on this specific resource? Cerbos returns both an allow/deny decision and a list of visible fields.&lt;/p&gt;

&lt;p&gt;Finally, the database query executes with RLS active. The application calls &lt;code&gt;set_config('app.current_user_id', ...)&lt;/code&gt; on the transaction before querying, and PostgreSQL enforces row visibility at the engine level.&lt;/p&gt;

&lt;p&gt;Header stripping, authentication, token minting, token validation with header projection, authorization policy, Cerbos, and RLS. Seven checkpoints. Each one reduces the blast radius of failures in the others.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Happens When You Skip a Layer
&lt;/h2&gt;

&lt;p&gt;Each layer exists because the others have blind spots.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skip header stripping?&lt;/strong&gt; An external attacker sets &lt;code&gt;x-mesh-identity: &amp;lt;forged-token&amp;gt;&lt;/code&gt; in their HTTP request. The token won't pass signature validation at the destination sidecar. But the projected headers (&lt;code&gt;x-ms2-user&lt;/code&gt;, &lt;code&gt;x-ms2-role&lt;/code&gt;) are the real danger. If these arrive from outside without being stripped, and the service reads them directly, the attacker controls the identity. Header stripping ensures no internal trust header survives from outside the mesh, regardless of whether it's a signed token or a raw projected header.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skip signed tokens (use raw headers instead)?&lt;/strong&gt; Any compromised pod can forge headers. Without cryptographic signing, there's no way to distinguish a legitimate identity assertion from one injected by a rogue process.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skip authorization policies?&lt;/strong&gt; A valid user with a valid token can call any service and access any resource, regardless of whether they should. Authentication without authorization is an access-all-pass.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skip RLS?&lt;/strong&gt; A bug in your application's authorization logic, a missing check or a broken filter, becomes a data breach. The database has no independent enforcement, so it returns whatever the query asks for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skip Vault (use local keys)?&lt;/strong&gt; If Auth Service is compromised, the attacker has the signing key. They can mint tokens for any user with any role, indefinitely, until you detect the breach and rotate. With Vault Transit, the key never leaves Vault. You revoke Auth Service access and rotate immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a Service Mesh
&lt;/h2&gt;

&lt;p&gt;The core idea: your application service has a simple contract. It reads &lt;code&gt;x-ms2-user&lt;/code&gt; and &lt;code&gt;x-ms2-role&lt;/code&gt; from request headers and does its business logic. It doesn't know about mTLS certificates, JWT validation, OIDC flows, or policy engines. The sidecar proxy handles all of that.&lt;/p&gt;

&lt;p&gt;This matters for three reasons.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;First, you can add services to the mesh without rewriting their auth logic. The security infrastructure wraps around them.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Second, when the security architecture evolves (new signing algorithm, new policy engine, new identity provider) application code doesn't change. Only mesh configuration does.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Third, application developers focus on domain logic while security is configured at the infrastructure layer. These concerns don't bleed into each other.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The complexity is real. You need Kubernetes knowledge, you need to understand Envoy, you need to debug sidecar injection and filter ordering. But the tradeoff is concrete: instead of implementing auth in every single service (and maybe getting it wrong in one of them), you configure it once at the infrastructure layer. The complexity is upfront and centralized instead of distributed across every service where it's invisible until it fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;The next post peels back the identity flow, from the gateway &lt;code&gt;EnvoyFilter&lt;/code&gt; that strips headers, through the &lt;code&gt;ExtAuthz&lt;/code&gt; check, to the signed mesh token landing on a downstream service. Real YAML, real Python, real attack scenarios.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code:
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/Kaustav-Sarkar/Istio-Defense-In-Depth" rel="noopener noreferrer"&gt;https://github.com/Kaustav-Sarkar/Istio-Defense-In-Depth&lt;/a&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>istio</category>
      <category>microservices</category>
      <category>cybersecurity</category>
    </item>
  </channel>
</rss>
