<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged istio</title>
<link>https://coderlegion.com/tag/istio</link>
<description>Powered by Question2Answer</description>
<item>
<title>Patterns, Anti-Patterns, and Operational Reality</title>
<link>https://coderlegion.com/26029/patterns-anti-patterns-and-operational-reality</link>
<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;h3 id=&quot;pattern-the-sidecar-contract&quot;&gt;Pattern: The Sidecar Contract&lt;/h3&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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;async def get_ms2_headers(
    x_ms2_user: str | None = Header(None),
    x_ms2_role: str | None = Header(None),
    x_request_id: str | None = Header(None),
):
    if not x_ms2_user or not x_ms2_role:
        raise HTTPException(status_code=401, detail=&quot;Missing required legacy headers&quot;)
    return {&quot;user&quot;: x_ms2_user, &quot;role&quot;: x_ms2_role, &quot;request_id&quot;: x_request_id}
&lt;/code&gt;&lt;/pre&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;h3 id=&quot;pattern-namespace-segregation-by-function&quot;&gt;Pattern: Namespace Segregation by Function&lt;/h3&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 &quot;any workload in &lt;code&gt;zt-apps&lt;/code&gt;&quot; from &quot;Auth Service running as &lt;code&gt;auth-service-sa&lt;/code&gt;.&quot;&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;h3 id=&quot;pattern-signed-tokens-over-forwarded-credentials&quot;&gt;Pattern: Signed Tokens Over Forwarded Credentials&lt;/h3&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;h3 id=&quot;pattern-fail-closed-by-default&quot;&gt;Pattern: Fail-Closed by Default&lt;/h3&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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;# Scale auth to 0, confirm requests fail-closed
kubectl scale deployment auth-service -n zt-apps --replicas=0
sleep 10
response=$(curl -s -o /dev/null -w &quot;%{http_code}&quot; https://app.localtest.me/api/profile/1)
assert_not_equal &quot;$response&quot; &quot;200&quot;
kubectl scale deployment auth-service -n zt-apps --replicas=1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For protected routes, a failure that returns application data is a security bug.&lt;/p&gt;
&lt;h3 id=&quot;anti-pattern-mtls-only-security&quot;&gt;Anti-Pattern: mTLS-Only Security&lt;/h3&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 &quot;this connection is from Holiday Calendar,&quot; 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;h3 id=&quot;anti-pattern-security-in-application-code&quot;&gt;Anti-Pattern: Security in Application Code&lt;/h3&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;h3 id=&quot;anti-pattern-flat-trust-zones&quot;&gt;Anti-Pattern: Flat Trust Zones&lt;/h3&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;h3 id=&quot;operational-reality-debugging-auth-failures&quot;&gt;Operational Reality: Debugging Auth Failures&lt;/h3&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;img src=&quot;https://cdn.hashnode.com/uploads/covers/6a0c37b1fd930acd9e15653e/0fbf69b1-1015-448f-b371-4bbdaa8b4cf9.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;operational-reality-sidecar-overhead&quot;&gt;Operational Reality: Sidecar Overhead&lt;/h3&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;h3 id=&quot;operational-reality-testing-security-properties&quot;&gt;Operational Reality: Testing Security Properties&lt;/h3&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;h3 id=&quot;what-i-d-do-differently&quot;&gt;What I'd Do Differently&lt;/h3&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;h3 id=&quot;wrapping-up&quot;&gt;Wrapping Up&lt;/h3&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;img src=&quot;https://cdn.hashnode.com/uploads/covers/6a0c37b1fd930acd9e15653e/7503f4a7-29e2-426c-97d9-db84d7900db1.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;code&quot;&gt;Code&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Kaustav-Sarkar/Istio-Defense-In-Depth&quot;&gt;https://github.com/Kaustav-Sarkar/Istio-Defense-In-Depth&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26029/patterns-anti-patterns-and-operational-reality</guid>
<pubDate>Wed, 02 Sep 2026 11:41:09 +0000</pubDate>
</item>
<item>
<title>Signing Mesh Tokens with Vault</title>
<link>https://coderlegion.com/26026/signing-mesh-tokens-with-vault</link>
<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 &quot;mesh token&quot;), 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;h3 id=&quot;the-problem-secrets-in-pods&quot;&gt;The Problem: Secrets in Pods&lt;/h3&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;h3 id=&quot;what-vault-solves-and-what-it-does-not&quot;&gt;What Vault Solves, and What It Does Not&lt;/h3&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;h3 id=&quot;vault-transit-signing-without-holding-keys&quot;&gt;Vault Transit: Signing Without Holding Keys&lt;/h3&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;img src=&quot;https://cdn.hashnode.com/uploads/covers/6a0c37b1fd930acd9e15653e/39a6878b-4d35-4728-807c-9bbc73f6646c.png&quot; alt=&quot;Vault Transit signing: Auth Service makes two round-trips to Vault to fetch the key version and sign; the RSA private key never leaves Vault, and the final JWT is assembled from the returned signature&quot;&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def sign_payload(payload_bytes: bytes) -&amp;gt; str:
    &quot;&quot;&quot;Signs a payload using Vault Transit and returns the raw signature&quot;&quot;&quot;
    url = f&quot;{settings.VAULT_URL}/v1/transit/sign/mesh-identity&quot;
    headers = {&quot;X-Vault-Token&quot;: settings.VAULT_TOKEN}

    encoded_payload = base64.b64encode(payload_bytes).decode(&quot;utf-8&quot;)
    data = {
        &quot;input&quot;: encoded_payload,
        &quot;hash_algorithm&quot;: &quot;sha2-256&quot;,
        &quot;signature_algorithm&quot;: &quot;pkcs1v15&quot;,
    }

    response = _client.post(url, headers=headers, json=data)
    response.raise_for_status()
    result = response.json()

    # Vault returns the signature as &quot;vault:v&amp;lt;version&amp;gt;:&amp;lt;base64sig&amp;gt;&quot;; strip the prefix for JWS.
    sig = result[&quot;data&quot;][&quot;signature&quot;]
    parts = sig.split(&quot;:&quot;)
    if len(parts) &amp;gt;= 3:
        return parts[2]
    return sig
&lt;/code&gt;&lt;/pre&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;h3 id=&quot;the-jwks-endpoint-public-key-distribution&quot;&gt;The JWKS Endpoint: Public Key Distribution&lt;/h3&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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def get_jwks() -&amp;gt; dict:
    &quot;&quot;&quot;Format Vault public keys as JWKS&quot;&quot;&quot;
    keys = vault_client.get_public_keys()
    jwks = {&quot;keys&quot;: []}
    for version, key_info in keys.items():
        public_key = serialization.load_pem_public_key(key_info[&quot;public_key&quot;].encode(&quot;utf-8&quot;))
        if isinstance(public_key, RSAPublicKey):
            numbers = public_key.public_numbers()
            jwks[&quot;keys&quot;].append({
                &quot;kty&quot;: &quot;RSA&quot;,
                &quot;kid&quot;: str(version),
                &quot;use&quot;: &quot;sig&quot;,
                &quot;alg&quot;: &quot;RS256&quot;,
                &quot;n&quot;: int_to_base64url(numbers.n),
                &quot;e&quot;: int_to_base64url(numbers.e),
            })
    return jwks
&lt;/code&gt;&lt;/pre&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;h3 id=&quot;key-rotation&quot;&gt;Key Rotation&lt;/h3&gt;
&lt;p&gt;Rotation happens in two steps: create a new key version, then invalidate old versions.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/bin/bash
# Step 1: Rotate — creates a new key version, old versions remain valid
vault write -f transit/keys/mesh-identity/rotate

# Step 2: Invalidate — bumps min_decryption_version so old keys can't verify
vault write transit/keys/mesh-identity/config \
  min_decryption_version=$NEW_LATEST
&lt;/code&gt;&lt;/pre&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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;if [ &quot;$1&quot; = &quot;--grace&quot; ]; then
    echo &quot;Grace period: waiting 300s for in-flight tokens to expire...&quot;
    sleep 300
fi
vault write transit/keys/mesh-identity/config \
    min_decryption_version=$NEW_LATEST
&lt;/code&gt;&lt;/pre&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;img src=&quot;https://cdn.hashnode.com/uploads/covers/6a0c37b1fd930acd9e15653e/3c0ce24c-7d12-4457-ada1-fd9c2a8e852c.png&quot; alt=&quot;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&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;vault-token-scoping&quot;&gt;Vault Token Scoping&lt;/h3&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;h3 id=&quot;blast-radius-what-happens-when-things-break&quot;&gt;Blast Radius: What Happens When Things Break&lt;/h3&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;h3 id=&quot;the-anti-patterns&quot;&gt;The Anti-Patterns&lt;/h3&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 &quot;sensitive config.&quot;&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 &quot;can someone on our team see this secret&quot; to &quot;what happens when one pod in a 50-service mesh is compromised.&quot;&lt;/p&gt;
&lt;h3 id=&quot;what-s-next&quot;&gt;What's Next&lt;/h3&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;h3 id=&quot;code-1&quot;&gt;Code&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Kaustav-Sarkar/Istio-Defense-In-Depth&quot;&gt;https://github.com/Kaustav-Sarkar/Istio-Defense-In-Depth&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26026/signing-mesh-tokens-with-vault</guid>
<pubDate>Wed, 02 Sep 2026 02:07:34 +0000</pubDate>
</item>
<item>
<title>The Identity Flow: From Gateway to Database</title>
<link>https://coderlegion.com/26025/the-identity-flow-from-gateway-to-database</link>
<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;img src=&quot;https://cdn.hashnode.com/uploads/covers/6a0c37b1fd930acd9e15653e/36558b98-f8f8-4da1-8ef3-327a1f3eaa47.png&quot; alt=&quot;The identity flow: eight checkpoints from the Istio gateway to the PostgreSQL row-level security query&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-trust-boundary&quot;&gt;The Trust Boundary&lt;/h3&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;h3 id=&quot;step-1-header-stripping&quot;&gt;Step 1: Header Stripping&lt;/h3&gt;
&lt;p&gt;Before authentication, before routing, before anything else, a Lua EnvoyFilter removes every internal trust header from the incoming request:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: gateway-prestrip
  namespace: istio-system
spec:
  priority: -100
  workloadSelector:
    labels:
      istio: ingressgateway
  configPatches:
    - applyTo: HTTP_FILTER
      match:
        context: GATEWAY
        listener:
          filterChain:
            filter:
              name: &quot;envoy.filters.network.http_connection_manager&quot;
              subFilter:
                name: &quot;envoy.filters.http.ext_authz&quot;
      patch:
        operation: INSERT_BEFORE
        value:
          name: envoy.filters.http.lua.prestrip
          typed_config:
            &quot;@type&quot;: type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
            inlineCode: |
              function envoy_on_request(request_handle)
                local headers_to_remove = {}
                for key, value in pairs(request_handle:headers()) do
                  local lower_key = string.lower(key)
                  if string.match(lower_key, &quot;^x%-platform%-&quot;) or
                     string.match(lower_key, &quot;^x%-role%-&quot;) or
                     lower_key == &quot;x-mesh-identity&quot; or
                     string.match(lower_key, &quot;^x%-ms%d%-user&quot;) or
                     string.match(lower_key, &quot;^x%-ms%d%-role&quot;) then
                    table.insert(headers_to_remove, lower_key)
                  end
                end
                for _, key in ipairs(headers_to_remove) do
                  request_handle:headers():remove(key)
                end
              end
&lt;/code&gt;&lt;/pre&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;h3 id=&quot;step-2-authentication-extauthz-&quot;&gt;Step 2: Authentication (ExtAuthz)&lt;/h3&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, &quot;istio-security://users/&amp;lt;username&amp;gt;&quot;)&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;h3 id=&quot;step-3-route-level-authorization&quot;&gt;Step 3: Route-Level Authorization&lt;/h3&gt;
&lt;p&gt;Before minting a token, Auth Service checks a coarse policy map. This is the first authorization gate:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Coarse route/role policy map (ext_authz.py)
if path.startswith(&quot;/api/profile/&quot;):
    allowed_roles = {&quot;employee&quot;, &quot;manager&quot;, &quot;hr_admin&quot;, &quot;it_admin&quot;}
    decision[&quot;audience&quot;] = [&quot;ms1-profile-aggregator&quot;, &quot;ms2-employee-details&quot;, &quot;ms3-hardware-assets&quot;]
elif path.startswith(&quot;/api/holidays&quot;) and method == &quot;GET&quot;:
    allowed_roles = {&quot;employee&quot;, &quot;manager&quot;, &quot;hr_admin&quot;, &quot;it_admin&quot;, &quot;public_data_admin&quot;, &quot;security_auditor&quot;}
    decision[&quot;audience&quot;] = [&quot;ms4-holiday-calendar&quot;]
elif path.startswith(&quot;/api/holidays&quot;) and method in (&quot;POST&quot;, &quot;PUT&quot;, &quot;DELETE&quot;, &quot;PATCH&quot;):
    allowed_roles = {&quot;public_data_admin&quot;, &quot;hr_admin&quot;}
    decision[&quot;audience&quot;] = [&quot;ms4-holiday-calendar&quot;]
else:
    decision[&quot;reason&quot;] = &quot;Route not defined in coarse policy map&quot;
    return decision

user_roles = set(decision[&quot;roles&quot;])
if not allowed_roles.intersection(user_roles):
    decision[&quot;reason&quot;] = &quot;Unauthorized role for route&quot;
    return decision
&lt;/code&gt;&lt;/pre&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;h3 id=&quot;step-4-token-minting&quot;&gt;Step 4: Token Minting&lt;/h3&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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;header = {&quot;alg&quot;: &quot;RS256&quot;, &quot;typ&quot;: &quot;JWT&quot;, &quot;kid&quot;: str(vault_key_version)}

payload = {
    &quot;iss&quot;: &quot;auth-service&quot;,
    &quot;sub&quot;: subject_uuid,
    &quot;preferred_username&quot;: username,
    &quot;roles&quot;: [&quot;employee&quot;, &quot;manager&quot;],
    &quot;roles_csv&quot;: &quot;employee,manager&quot;,
    &quot;aud&quot;: [&quot;ms1-profile-aggregator&quot;, &quot;ms2-employee-details&quot;, &quot;ms3-hardware-assets&quot;],
    &quot;act&quot;: {&quot;sub&quot;: &quot;ms1-profile-aggregator&quot;},
    &quot;exp&quot;: now + 300,  # 5 minutes
    &quot;jti&quot;: str(uuid4()),
    &quot;request_id&quot;: request_id
}
&lt;/code&gt;&lt;/pre&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;h3 id=&quot;step-5-token-validation-and-header-projection&quot;&gt;Step 5: Token Validation and Header Projection&lt;/h3&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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: header-projection-ms2
  namespace: zt-apps
spec:
  priority: 10
  workloadSelector:
    labels:
      app: ms2-employee-details
  configPatches:
    - applyTo: HTTP_FILTER
      match:
        context: SIDECAR_INBOUND
        listener:
          filterChain:
            filter:
              name: &quot;envoy.filters.network.http_connection_manager&quot;
              subFilter:
                name: &quot;envoy.filters.http.jwt_authn&quot;
      patch:
        operation: INSERT_BEFORE
        value:
          name: envoy.filters.http.lua.projection
          typed_config:
            &quot;@type&quot;: type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
            inlineCode: |
              function envoy_on_request(request_handle)
                request_handle:headers():remove(&quot;x-ms2-user&quot;)
                request_handle:headers():remove(&quot;x-ms2-role&quot;)
              end
&lt;/code&gt;&lt;/pre&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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: security.istio.io/v1beta1
kind: RequestAuthentication
metadata:
  name: mesh-identity-ms2
  namespace: zt-apps
spec:
  selector:
    matchLabels:
      app: ms2-employee-details
  jwtRules:
    - issuer: &quot;auth-service&quot;
      jwksUri: &quot;http://auth-service.zt-apps.svc.cluster.local:8000/auth/jwks&quot;
      forwardOriginalToken: false
      fromHeaders:
        - name: &quot;x-mesh-identity&quot;
      outputClaimToHeaders:
        - header: &quot;x-ms2-user&quot;
          claim: &quot;sub&quot;
        - header: &quot;x-ms2-role&quot;
          claim: &quot;roles_csv&quot;
&lt;/code&gt;&lt;/pre&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 &quot;a JWT is required.&quot; 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;h3 id=&quot;step-6-authorizationpolicy&quot;&gt;Step 6: AuthorizationPolicy&lt;/h3&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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
  name: allow-ms1-to-ms2
  namespace: zt-apps
spec:
  selector:
    matchLabels:
      app: ms2-employee-details
  action: ALLOW
  rules:
    - from:
        - source:
            principals:
              - cluster.local/ns/zt-apps/sa/ms1-profile-aggregator-sa
      to:
        - operation:
            ports: [&quot;8000&quot;]
      when:
        - key: request.auth.claims[aud]
          values: [&quot;ms2-employee-details&quot;]
        - key: request.auth.claims[act][sub]
          values: [&quot;ms1-profile-aggregator&quot;]
&lt;/code&gt;&lt;/pre&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;h3 id=&quot;step-7-the-service-contract&quot;&gt;Step 7: The Service Contract&lt;/h3&gt;
&lt;p&gt;After all of this, what does the service actually see? Two headers:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;async def get_ms2_headers(
    x_ms2_user: str | None = Header(None),
    x_ms2_role: str | None = Header(None),
    x_request_id: str | None = Header(None),
):
    if not x_ms2_user or not x_ms2_role:
        raise HTTPException(status_code=401, detail=&quot;Missing required legacy headers&quot;)
    return {&quot;user&quot;: x_ms2_user, &quot;role&quot;: x_ms2_role, &quot;request_id&quot;: x_request_id}
&lt;/code&gt;&lt;/pre&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 &quot;legacy&quot; 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;h3 id=&quot;step-8-database-identity-context&quot;&gt;Step 8: Database Identity Context&lt;/h3&gt;
&lt;p&gt;The final step. Before executing any query, the service sets the transaction context:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;async def set_rls_context(session: AsyncSession, user_id: str, roles: str, request_id: str):
    await session.execute(
        text(&quot;SELECT set_config('app.current_user_id', :user_id, true)&quot;),
        {&quot;user_id&quot;: user_id},
    )
    await session.execute(
        text(&quot;SELECT set_config('app.current_roles', :roles, true)&quot;),
        {&quot;roles&quot;: roles},
    )
    if request_id:
        await session.execute(
            text(&quot;SELECT set_config('app.request_id', :request_id, true)&quot;),
            {&quot;request_id&quot;: request_id},
        )
&lt;/code&gt;&lt;/pre&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;h3 id=&quot;failure-behavior&quot;&gt;Failure Behavior&lt;/h3&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;h3 id=&quot;the-complete-chain&quot;&gt;The Complete Chain&lt;/h3&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;h3 id=&quot;what-s-next-1&quot;&gt;What's Next&lt;/h3&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;h3 id=&quot;code-2&quot;&gt;Code&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Kaustav-Sarkar/Istio-Defense-In-Depth&quot;&gt;https://github.com/Kaustav-Sarkar/Istio-Defense-In-Depth&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26025/the-identity-flow-from-gateway-to-database</guid>
<pubDate>Tue, 01 Sep 2026 22:13:32 +0000</pubDate>
</item>
</channel>
</rss>