{"slug": "zero-trust-workload-identity-in-kubernetes-with-spiffe-spire-and-cilium", "title": "Zero-Trust Workload Identity in Kubernetes with Spiffe, Spire, and Cilium", "summary": "Kubernetes network policies based on IP addresses become insecure when pods are rescheduled, as IPs change. SPIFFE and SPIRE provide cryptographic workload identities, and Cilium enforces mutual TLS using eBPF without sidecars, ensuring services authenticate via certificates rather than IPs.", "body_md": "Your network policy says: allow traffic from `10.0.1.45`\n\n.\n\nYesterday, `10.0.1.45`\n\nwas your payment service. Today, after a rolling deployment, it's your logging agent. Your payment service is now at `10.0.1.89`\n\n.\n\nKubernetes has already updated all the endpoints and service records — but your network policy has no idea. It silently allows traffic through based on an IP address that no longer belongs to the workload you intended to trust.\n\nThis is the workload identity problem. IP addresses aren't an identity, they're a location. And in a Kubernetes cluster, location changes constantly. Building security policy on top of IP addresses means your security posture silently degrades every time a pod is scheduled, rescheduled, or scaled.\n\nThe answer is cryptographic workload identity: every workload gets a certificate-backed identity that proves who it is, not where it is. Services authenticate each other using those certificates before exchanging any data. If the certificate doesn't match, the connection is refused, regardless of what IP address it came from.\n\nThis is what SPIFFE and SPIRE provide. And this is how Cilium enforces it using eBPF, without injecting a sidecar into every pod.\n\nIn this article you'll understand how the SPIFFE identity model works, deploy SPIRE to issue cryptographic identities to workloads, and use Cilium's built-in SPIRE integration to enforce mutual TLS between services without touching your application code.\n\n## Prerequisites\n\nFamiliarity with Kubernetes RBAC and pod security —\n\n[this handbook](https://www.freecodecamp.org/news/how-to-secure-a-kubernetes-cluster-handbook/)covers the foundationsFamiliarity with TLS certificates and Kubernetes Secrets —\n\n[this handbook](https://www.freecodecamp.org/news/how-to-encrypt-kubernetes-traffic/)covers cert-manager and certificate conceptsHelm 3 and the Cilium CLI installed\n\nA kind cluster — you'll create a fresh one with Cilium as the CNI in this article\n\nPatience: this is the most complex demo I've covered in this group of articles. SPIRE has more moving parts than anything else covered so far.\n\nAll demo files are in the [companion GitHub repository](https://github.com/Caesarsage/DevOps-Cloud-Projects/tree/main/intermediate/k8/security/cilium-mtls).\n\n## Table of Contents\n\n## The Workload Identity Problem\n\nThe opening scenario isn't theoretical. In Kubernetes, pods are ephemeral. The scheduler can place a pod on any node, and a pod's IP address is assigned at scheduling time from the node's IP pool.\n\nWhen a pod is deleted and recreated through a rolling deployment, a node drain, or an autoscaler event, it gets a new IP address. If you've written a NetworkPolicy that says, \"allow traffic from this IP\", that policy is now pointing at nothing, or worse, at a different workload.\n\nKubernetes service names help here for east-west traffic — a Service name resolves consistently regardless of which pods back it. But a NetworkPolicy based on a Service name is still a label selector match, not a cryptographic assertion. Any pod that can spoof the right labels can bypass it.\n\nWhat you actually want is this: before service A sends a request to service B, service B proves its identity cryptographically. If service B can't prove it is who it claims to be, service A refuses the connection. This is mutual TLS, and the key question is: where do the identities come from?\n\nSPIFFE answers that question.\n\n## How SPIFFE Works\n\nSPIFFE — Secure Production Identity Framework for Everyone — is a CNCF standard that defines a model for workload identity. It doesn't implement anything by itself. It specifies the format of identities, the API for requesting them, and the trust model that makes them verifiable across services, clusters, and clouds. SPIRE is the reference implementation of that specification.\n\n### SPIFFE IDs and Trust Domains\n\nA SPIFFE identity is a URI with a specific format:\n\n```\nspiffe://<trust-domain>/<workload-path>\n```\n\nThe trust domain is a string that identifies the administrative boundary — typically your organisation, cluster, or environment. Everything within the same trust domain can verify each other's identities. Identities from different trust domains require explicit federation configuration.\n\nSome concrete examples:\n\n```\nspiffe://payments.corp/ns/production/sa/checkout\nspiffe://analytics.corp/ns/data/sa/pipeline-worker\nspiffe://cluster.local/ns/monitoring/sa/prometheus\n```\n\nThe path after the trust domain is arbitrary — it's defined by your SPIRE configuration and typically encodes the Kubernetes namespace and service account of the workload.\n\n### SVIDs: The Cryptographic Identity Document\n\nAn SVID — SPIFFE Verifiable Identity Document — is how a SPIFFE identity is materialised into something a service can actually use.\n\nThere are two SVID formats.\n\nAn **X.509 SVID** is a standard TLS certificate where the SPIFFE ID is embedded in the Subject Alternative Name (SAN) URI field. Because it's a standard X.509 certificate, any TLS library can use it without modification.\n\nThe workload presents this certificate in a TLS handshake, and the peer verifies the certificate was signed by a trusted SPIRE server. This is the format used for long-lived connections like gRPC streams.\n\nA **JWT SVID** is a signed JSON Web Token containing the SPIFFE ID as a claim. It's suitable for request-based authentication over HTTP — pass it in an Authorization header, and the receiving service verifies the signature.\n\nJWT SVIDs are shorter-lived than X.509 SVIDs and scoped to a specific audience to prevent token reuse across services.\n\nFor Cilium's mutual authentication, X.509 SVIDs are used. The rest of this article focuses on X.509.\n\n### The Trust Bundle\n\nFor service A to verify service B's certificate, service A needs to know which Certificate Authority signed it. In SPIFFE, this is called the trust bundle — the set of CA certificates that are trusted within a trust domain.\n\nSPIRE makes the trust bundle available via the Workload API. When a workload requests its identity, it also receives the current trust bundle. When the SPIRE server rotates its CA, it distributes the new trust bundle to all agents, which push it to all workloads. Your application never has to manage trust bundles manually.\n\n## How SPIRE Works\n\nSPIRE is the engine that issues SVIDs and manages the identity lifecycle. Understanding its architecture is what makes the Cilium integration make sense.\n\n### SPIRE Server and SPIRE Agent\n\nSPIRE has two main components. The **SPIRE Server** is the central CA. It maintains a registry of workload entries (records that describe which SPIFFE IDs should be issued to which workloads). It issues SVIDs to agents on behalf of workloads, and it's the root of trust for the entire trust domain.\n\nThe **SPIRE Agent** runs on every node as a DaemonSet. It has two jobs. First, it proves to the SPIRE Server that it's running on a legitimate node. This is called node attestation. Second, it exposes the SPIFFE Workload API on a Unix socket on the node, which workloads use to request their SVIDs.\n\nThe agent caches SVIDs locally so that a temporary loss of connection to the SPIRE Server doesn't immediately break workload identity.\n\nThis split — central server, per-node agents — is deliberate. Workloads never contact the SPIRE Server directly. They only talk to the agent on their own node. The agent mediates all identity requests, which limits the blast radius if a node is compromised.\n\n### Node Attestation\n\nWhen a SPIRE Agent starts up on a new node, it needs to prove its own identity to the SPIRE Server before it can serve identities to workloads. This is node attestation.\n\nIn Kubernetes, SPIRE uses **PSAT** — Projected Service Account Tokens — for node attestation. The agent presents a Kubernetes service account token that is projected specifically for the SPIRE server's audience. The SPIRE Server contacts the Kubernetes API to verify the token, confirms the agent is running in the expected namespace with the expected service account, and issues the agent its own SVID.\n\nThis is the reason SPIRE requires specific Kubernetes API flags. The kube-apiserver must be configured to support projected service account tokens with the right audience, which is why the kind cluster config in the demo below sets `--api-audiences`\n\nand `--service-account-issuer`\n\n.\n\n### Workload Attestation\n\nOnce a node has been attested, its agent can attest workloads. When a workload connects to the Workload API socket and requests an SVID, the agent collects facts about that workload (like its Kubernetes namespace, service account, pod name, and labels) by querying the Kubernetes API. It matches those facts against the workload entries registered in the SPIRE Server. If a matching entry exists, the agent issues the corresponding SVID.\n\nA workload entry looks like this:\n\n```\nSPIFFE ID: spiffe://example.org/ns/production/sa/checkout\nParent ID: spiffe://example.org/spire/agent/k8s_psat/default/<node-uid>\nSelectors:\n  k8s:ns:production\n  k8s:sa:checkout\n```\n\nThe selectors describe the Kubernetes facts that must match. A pod running in the `production`\n\nnamespace with service account `checkout`\n\nwill receive the SPIFFE ID `spiffe://example.org/ns/production/sa/checkout`\n\n. Any other pod will not.\n\n### SVID Issuance and Rotation\n\nSVIDs are short-lived by design. The default TTL for X.509 SVIDs in SPIRE is one hour. The SPIRE Agent automatically rotates them in the background — generating a new key pair, requesting a fresh SVID from the server, and making the new SVID available on the Workload API before the old one expires.\n\nWorkloads that use the Workload API directly or tools like the SPIFFE CSI driver get the new SVID transparently.\n\nShort-lived credentials are the zero-trust way. If a workload's SVID is compromised, it's only valid for an hour. Compare that to a Kubernetes service account token, which was historically valid forever.\n\n## How Cilium Implements Mutual TLS with SPIFFE\n\nTraditional approaches to service mesh mTLS (like Istio or Linkerd) inject a sidecar proxy into every pod. The proxy intercepts all traffic and handles the TLS handshake. The application has no idea TLS is happening. The sidecar adds memory overhead (roughly 50–100MB per pod for Envoy), an extra network hop on every request, and a complex certificate injection mechanism.\n\nCilium takes a different path. Rather than injecting a proxy, it handles authentication at the network layer using eBPF. The Cilium agent running on each node intercepts connections, performs the mutual TLS handshake using SPIFFE SVIDs, and enforces the authentication result — all in the kernel, without any user-space proxy.\n\nThe mechanism works like this. When pod A initiates a connection to pod B, the Cilium agent on pod A's node intercepts the connection. It retrieves pod A's SVID from the SPIRE Workload API. It checks whether there's a `CiliumNetworkPolicy`\n\nrequiring mutual authentication for this connection. If there is, it performs a TLS handshake with the Cilium agent on pod B's node, presenting pod A's SVID and requesting pod B's SVID in return.\n\nBoth agents verify the SVID against the SPIRE trust bundle. If both SVIDs are valid and the policy allows the connection, it proceeds. If either SVID is invalid or missing, the connection is dropped.\n\nThe application on pod A receives data from the application on pod B. Neither application wrote any TLS code. Neither has a sidecar. The authentication happened entirely in the Cilium agents on their respective nodes.\n\nIn Cilium's model, the Cilium agent itself gets a SPIFFE identity from SPIRE. It acts as a delegate identity that can request SVIDs on behalf of workloads.\n\nThis is slightly different from the standalone SPIRE model where each workload requests its own SVID directly. The Cilium operator registers workload entries in SPIRE automatically based on the Kubernetes Identities it manages, so you don't need to manually create SPIRE entries for every pod.\n\n## Demo 1 — Install Cilium with SPIRE Integration\n\nYou'll create a kind cluster with Cilium as the CNI and enable its built-in SPIRE integration in a single Helm command.\n\n### Step 1: Install the Cilium CLI\n\n```\n# macOS\nbrew install cilium-cli\n\n# Linux\nCILIUM_CLI_VERSION=$(curl -s https://raw.githubusercontent.com/cilium/cilium-cli/main/stable.txt)\ncurl -L --remote-name-all \\\n  https://github.com/cilium/cilium-cli/releases/download/${CILIUM_CLI_VERSION}/cilium-linux-amd64.tar.gz\nsudo tar -xzf cilium-linux-amd64.tar.gz -C /usr/local/bin\n```\n\n### Step 2: Create a kind Cluster Without a Default CNI\n\nkind's default CNI (kindnet) must be disabled so Cilium can take its place. Save this as `kind-cilium.yaml`\n\n:\n\n```\n# kind-cilium.yaml\nkind: Cluster\napiVersion: kind.x-k8s.io/v1alpha4\nnodes:\n  - role: control-plane\n  - role: worker\n  - role: worker\nnetworking:\n  disableDefaultCNI: true   # Required: let Cilium be the CNI\n  kubeProxyMode: none       # Cilium replaces kube-proxy too\nkind create cluster --name k8s-mtls --config kind-cilium.yaml\n```\n\nThe nodes will be in a `NotReady`\n\nstate until Cilium is installed. This is expected because there's no CNI yet.\n\n### Step 3: Install Cilium with SPIRE Enabled\n\nBecause Step 2 set `kubeProxyMode: none`\n\n, Cilium has to play the kube-proxy role itself. That means its bootstrap pods can't reach the API server via the `kubernetes`\n\nService ClusterIP, because nothing is routing it yet.\n\nYou have to pass the API server's real address up front. Grab the kind control-plane's IP from Docker:\n\n```\nAPI_SERVER_IP=$(docker inspect k8s-mtls-control-plane \\\n  --format='{{ .NetworkSettings.Networks.kind.IPAddress }}')\necho \"API_SERVER_IP=$API_SERVER_IP\"\n```\n\nThen install Cilium with SPIRE:\n\n```\nhelm repo add cilium https://helm.cilium.io/\nhelm repo update\n\nhelm upgrade cilium cilium/cilium \\\n  --install \\\n  --namespace kube-system \\\n  --set kubeProxyReplacement=true \\\n  --set k8sServiceHost=${API_SERVER_IP} \\\n  --set k8sServicePort=6443 \\\n  --set authentication.enabled=true \\\n  --set authentication.mutual.spire.enabled=true \\\n  --set authentication.mutual.spire.install.enabled=true \\\n  --set authentication.mutual.spire.install.server.dataStorage.enabled=false\n```\n\nA few of these flags are easy to miss but each is load-bearing:\n\n`kubeProxyReplacement=true`\n\n: Cilium installs its eBPF-based replacement for kube-proxy. Mandatory whenever the kind config sets`kubeProxyMode: none`\n\n.`k8sServiceHost`\n\n/`k8sServicePort`\n\n: direct API server address used during bootstrap, before Cilium can route the Service ClusterIP. On EKS/GKE/AKS you don't need this because kube-proxy is still present during install.`authentication.enabled=true`\n\n: required alongside`authentication.mutual.spire.enabled=true`\n\n. The chart's`validate.yaml`\n\nrejects the install with`SPIRE integration requires .Values.authentication.enabled=true and .Values.authentication.mutual.spire.enabled=true`\n\nif you set only the mutual flag.`dataStorage.enabled=false`\n\n: switches the SPIRE server from a PVC-backed datastore to in-memory. Fine for a lab cluster, but in production leave this enabled and ensure your cluster has PersistentVolume support.\n\nNotice there's no `--wait`\n\nflag here. On a fresh cluster, `--wait`\n\nwill appear to fail with `context deadline exceeded`\n\nbecause the install is racey by design. The SPIRE server has to schedule on a `NotReady`\n\nnode thanks to its tolerations, then Cilium agents come up using SPIRE, then nodes flip to `Ready`\n\n. Let the install return immediately and watch the pods come up over the next ~2 minutes:\n\n```\nkubectl get pods -A -w\n```\n\n### Step 4: Verify the Installation\n\n```\ncilium status --wait\n/¯¯\\\n /¯¯\\__/¯¯\\    Cilium:             OK\n \\__/¯¯\\__/    Operator:           OK\n /¯¯\\__/¯¯\\    Envoy DaemonSet:    OK\n \\__/¯¯\\__/    Hubble Relay:       disabled\n    \\__/       ClusterMesh:        disabled\n\nDaemonSet              cilium             Desired: 3, Ready: 3/3, Available: 3/3\nDaemonSet              cilium-envoy       Desired: 3, Ready: 3/3, Available: 3/3\nDeployment             cilium-operator    Desired: 2, Ready: 2/2, Available: 2/2\n```\n\nThree Cilium agents, one per node, including the control-plane (no taints in the kind config). Check the SPIRE components in the `cilium-spire`\n\nnamespace:\n\n```\nkubectl get all -n cilium-spire\nNAME                    READY   STATUS    RESTARTS   AGE\npod/spire-agent-2cpsr   1/1     Running   0          3m\npod/spire-agent-klhjx   1/1     Running   0          3m\npod/spire-agent-vhsnc   1/1     Running   0          3m\npod/spire-server-0      2/2     Running   0          3m\n\nNAME                              TYPE        CLUSTER-IP    PORT(S)    AGE\nservice/spire-server              ClusterIP   10.96.x.x     8081/TCP   3m\n\nNAME                          DESIRED   CURRENT   READY   AGE\ndaemonset.apps/spire-agent    3         3         3       3m\n\nNAME                             READY   AGE\nstatefulset.apps/spire-server    1/1     3m\n```\n\nOne SPIRE agent per node. The SPIRE server is a StatefulSet with two containers: the server itself plus the SPIRE controller manager, which automatically creates workload registration entries for Cilium identities.\n\nRun a health check on the SPIRE server:\n\n```\nkubectl exec -n cilium-spire spire-server-0 -c spire-server -- \\\n  /opt/spire/bin/spire-server healthcheck\nServer is healthy.\n```\n\nVerify the SPIRE agents have been attested:\n\n```\nkubectl exec -n cilium-spire spire-server-0 -c spire-server -- \\\n  /opt/spire/bin/spire-server agent list\nFound 3 attested agents:\n\nSPIFFE ID         : spiffe://spiffe.cilium/spire/agent/k8s_psat/default/<node-uid-1>\nAttestation type  : k8s_psat\nExpiration time   : 2026-05-17 21:08:47 +0000 UTC\nSerial number     : 91532884191503307904684123063465502141\nCan re-attest     : true\n\nSPIFFE ID         : spiffe://spiffe.cilium/spire/agent/k8s_psat/default/<node-uid-2>\n...\n```\n\nThree agents, one per node, all attested via Kubernetes PSAT. The SPIRE server trusts every node and will issue SVIDs to workloads running on them.\n\nAt this point the identity platform is fully in place, but nothing is using it yet. Demo 1 built the machinery that *issues* cryptographic identities. Demo 2, which we'll walk through next, puts that machinery to work, turning those SVIDs into an enforced mutual-TLS policy between two real services. Keep the cluster from Demo 1 running, as Demo 2 builds directly on it.\n\n## Demo 2 — Enforce Mutual TLS with a CiliumNetworkPolicy\n\nPicking up in the same cluster from Demo 1, you'll deploy two services, enforce mutual authentication between them with a `CiliumNetworkPolicy`\n\n, verify that authenticated traffic flows, and confirm that unauthenticated connections are blocked.\n\nEvery request here is authenticated with the SVIDs that the SPIRE server you just verified hands out. These two demos are one continuous walkthrough, not standalone exercises.\n\n### Step 1: Deploy a Client and Server\n\nThis file contains both the server and the client — the client is a sleeping curl pod we'll use to exec into.\n\n```\n# echo-workloads.yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: echo-server\n  namespace: default\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: echo-server\n  template:\n    metadata:\n      labels:\n        app: echo-server\n    spec:\n      containers:\n        - name: echo-server\n          image: ealen/echo-server:latest\n          ports:\n            - containerPort: 80\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: echo-server\n  namespace: default\nspec:\n  selector:\n    app: echo-server\n  ports:\n    - port: 80\n      targetPort: 80\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: echo-client\n  namespace: default\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: echo-client\n  template:\n    metadata:\n      labels:\n        app: echo-client\n    spec:\n      containers:\n        - name: client\n          image: curlimages/curl:latest\n          command: [\"sleep\", \"infinity\"]\nkubectl apply -f echo-workloads.yaml\n# kubectl rollout status only takes one resource at a time\nkubectl rollout status deployment/echo-server -n default\nkubectl rollout status deployment/echo-client -n default\n```\n\n### Step 2: Confirm Traffic Flows Without Authentication\n\nBefore enforcing mTLS, confirm the client can reach the server:\n\n```\nCLIENT=$(kubectl get pod -l app=echo-client -o jsonpath='{.items[0].metadata.name}')\nkubectl exec $CLIENT -- curl -s http://echo-server/\n```\n\nYou should get a JSON response from the echo server. Traffic flows freely with no authentication.\n\n### Step 3: Apply a CiliumNetworkPolicy Requiring Mutual Authentication\n\nAdding `authentication.mode: required`\n\nto a `CiliumNetworkPolicy`\n\ntells Cilium to enforce mutual TLS for matching traffic. Both sides of the connection must present a valid SPIFFE SVID:\n\n```\n# mtls-policy.yaml\napiVersion: cilium.io/v2\nkind: CiliumNetworkPolicy\nmetadata:\n  name: echo-server-mtls\n  namespace: default\nspec:\n  endpointSelector:\n    matchLabels:\n      app: echo-server\n  ingress:\n    - fromEndpoints:\n        - matchLabels:\n            app: echo-client\n      authentication:\n        mode: required     # Require mutual TLS for this traffic\nkubectl apply -f mtls-policy.yaml\n```\n\n### Step 4: Verify Authenticated Traffic Still Flows\n\n```\nkubectl exec $CLIENT -- curl -s http://echo-server/\n```\n\nThe connection succeeds. Cilium intercepted it, performed the SPIFFE mTLS handshake between the Cilium agents on both pods' nodes, verified both SVIDs, and allowed the traffic through. The application on the client sent a plain HTTP request and received a response — the mutual authentication happened transparently at the network layer.\n\n### Step 5: Observe the Authentication with Hubble (Optional)\n\nHubble is Cilium's observability layer. It needs its own CLI:\n\n```\n# macOS\nbrew install hubble\n\n# Linux\nHUBBLE_VERSION=$(curl -s https://raw.githubusercontent.com/cilium/hubble/master/stable.txt)\ncurl -L --remote-name-all \\\n  https://github.com/cilium/hubble/releases/download/${HUBBLE_VERSION}/hubble-linux-amd64.tar.gz\nsudo tar -xzf hubble-linux-amd64.tar.gz -C /usr/local/bin\n```\n\nEnable Hubble in the cluster, then watch flows. `cilium hubble enable`\n\ndeploys Hubble Relay *and* restarts the Cilium agents to switch on the Hubble server inside them, so wait for it to settle before port-forwarding. If you skip the wait, the port-forward connects before Relay is listening, then dies with `connection reset by peer`\n\n/ `rpc error … EOF`\n\n:\n\n```\ncilium hubble enable\ncilium status --wait          # wait for \"Hubble Relay: OK\" before continuing\n\ncilium hubble port-forward &\n\n# Watch flows for the echo-server (Ctrl-C to stop)\nhubble observe --namespace default --pod echo-server --follow\n```\n\nTrigger another request in a second terminal:\n\n```\nkubectl exec $CLIENT -- curl -s http://echo-server/\n```\n\nIn the Hubble output you'll see:\n\n```\nℹ️  Hubble Relay is available at 127.0.0.1:4245\nJul  7 12:44:42.380: default/echo-client-86d446b8f-9bn5v:47500 (ID:2822) -> default/echo-server-7467b4b54d-5tvkz:80 (ID:30854) policy-verdict:none TRAFFIC_DIRECTION_UNKNOWN ALLOWED (TCP Flags: SYN)\nJul  7 12:44:42.380: default/echo-client-86d446b8f-9bn5v:47500 (ID:2822) -> default/echo-server-7467b4b54d-5tvkz:80 (ID:30854) to-endpoint FORWARDED (TCP Flags: SYN)\nJul  7 12:44:42.381: default/echo-client-86d446b8f-9bn5v:47500 (ID:2822) <- default/echo-server-7467b4b54d-5tvkz:80 (ID:30854) to-endpoint FORWARDED (TCP Flags: SYN, ACK)\n```\n\nThe `ALLOWED`\n\nverdict with the `policy-verdict`\n\nreason confirms the CiliumNetworkPolicy matched and authentication was verified. No sidecar involved — this happened in the Cilium agents.\n\n**Prefer a graphical view? Enable the Hubble UI.** Everything above is the API + terminal path (Relay on port 4245 backs the `hubble`\n\nCLI). Hubble also ships a web dashboard with a live service map — but it's a separate component that `cilium hubble enable`\n\ndoes *not* start by default:\n\n```\n# Add the UI (re-runs enable, keeps Relay, adds the hubble-ui deployment)\ncilium hubble enable --ui\n\n# Wait for it to be Ready before opening — same race as Relay. Skip this and\n# `cilium hubble ui` fails with \"connection refused\" on port 8081, because the\n# UI's frontend container isn't listening yet.\nkubectl -n kube-system rollout status deployment/hubble-ui --timeout=90s\n\n# Port-forwards hubble-ui and opens http://localhost:12000 in your browser\ncilium hubble ui\n```\n\nSelect the `default`\n\nnamespace from the dropdown. That's where the demo pods and the policy live. The map is *live*: it renders edges from flows as they happen, so an idle namespace looks empty. Trigger a request to light it up:\n\n```\nkubectl exec $CLIENT -- curl -s http://echo-server/\n```\n\nYou'll see a forwarded edge `echo-client → echo-server`\n\n. Click it (or open the flow table at the bottom) to read the `policy-verdict: ALLOWED`\n\n. Leave the UI open through Step 6. When you run the unauthorized-client test there, its connection shows up as a red *dropped* edge, the visual counterpart to the `curl`\n\ntimeout.\n\nThe UI has three parts.\n\nThe **service map** at the top draws each workload identity as a box and each observed connection as an edge colored by verdict: `echo-client → echo-server:80`\n\nis a solid green (forwarded) edge, while the box labelled `default`\n\n(that's the `unauthorized`\n\npod, which carries only the namespace identity because it has no `app`\n\nlabel, so Hubble names it after that) reaches `echo-server`\n\nover a red dashed (dropped) line. The 🔒 lock on `echo-server`\n\n's `→ 80 TCP`\n\nport marks that endpoint as mutually authenticated by the policy.\n\nThe **flow table** underneath logs one row per flow: source identity, destination identity, destination port, L7 info, `Verdict`\n\n, and timestamp. This lets you read both outcomes side by side, with `echo-client → echo-server`\n\nrows marked **forwarded** and `default → echo-server`\n\nrows marked **dropped**. This is the same allow/deny split as the CLI, one line per packet.\n\nThe **top bar** holds the namespace selector, a flow filter, the `Any verdict`\n\n/ `Visual`\n\ntoggle, and a live `flows/s`\n\nrate alongside the count of reporting nodes (`3/3`\n\n).\n\n### Step 6: Verify That a Pod Without the Matching Label is Blocked\n\nDeploy a third pod without the `echo-client`\n\nlabel and try to reach the server:\n\n```\n# unauthorized-client.yaml\napiVersion: v1\nkind: Pod\nmetadata:\n  name: unauthorized\n  namespace: default\nspec:\n  containers:\n    - name: client\n      image: curlimages/curl:latest\n      command: [\"sleep\", \"infinity\"]\nkubectl apply -f unauthorized-client.yaml\nkubectl wait --for=condition=Ready pod/unauthorized --timeout=60s\nkubectl exec unauthorized -- curl -sS --max-time 5 http://echo-server/\ncurl: (28) Connection timed out after 5000 milliseconds\n```\n\nThe connection times out. The `CiliumNetworkPolicy`\n\nonly permits ingress from pods with `app: echo-client`\n\n. A pod without that label gets no SVID match and no policy match. Cilium drops the traffic silently.\n\nThere are two gotchas to watch out for here. Run `kubectl wait`\n\nbefore exec. Run exec too soon after `apply`\n\nand you get `container not found (\"client\")`\n\nbecause the pod's container hasn't started yet.\n\nAnd use `curl -sS`\n\n, not plain `-s`\n\n. With only `-s`\n\n, curl swallows the error text and you just see `command terminated with exit code 28`\n\n. That's the same result — 28 *is* curl's timeout code — but the `-S`\n\nrestores the readable message. The fact that it times out (rather than \"connection refused\") is the signature of a policy *drop*: the packets are silently blackholed, not actively rejected. A refusal would return instantly with a different error.\n\n### Step 7: Check the Workload Entries in SPIRE\n\nCilium's SPIRE controller manager automatically created SPIFFE identities for the Cilium security identities in this cluster. You can see them:\n\n```\nkubectl exec -n cilium-spire spire-server-0 -c spire-server -- \\\n  /opt/spire/bin/spire-server entry show \\\n  -selector cilium:mutual-auth\n```\n\nEach entry maps a Cilium security identity to a SPIFFE ID. The Cilium operator manages this registry automatically, so you never need to register workloads manually when using Cilium's built-in integration.\n\n## Conclusion\n\nIP addresses are location, not identity. And in Kubernetes, location changes with every deployment, so any policy built on address matching silently degrades over time.\n\nCryptographic workload identity fixes that at the foundation. SPIFFE defines the model (a SPIFFE ID names a workload within a trust domain, an X.509 SVID materialises it into a certificate any TLS library can verify), and SPIRE implements it: the server is the CA and registry, while per-node agents attest via Kubernetes PSAT and issue short-lived, auto-rotating SVIDs.\n\nCilium wires that identity layer into the network. Add `authentication.mode: required`\n\nto a CiliumNetworkPolicy and its eBPF agents fetch both workloads' SVIDs, run the mutual TLS handshake, and enforce the verdict. There's no sidecar, no application changes, and near-zero overhead versus a service mesh. And you deployed the whole stack in a single Helm command: the complexity lives in the infrastructure, not in your code.\n\n## Cleanup (kind)\n\n```\n# Delete demo workloads\nkubectl delete deployment echo-server echo-client -n default\nkubectl delete service echo-server -n default\nkubectl delete pod unauthorized -n default\nkubectl delete ciliumnetworkpolicy echo-server-mtls -n default\n\n# Uninstall Cilium (helm doesn't delete the cilium-spire namespace it created)\nhelm uninstall cilium -n kube-system\nkubectl delete namespace cilium-spire\n\n# Delete the cluster (easiest reset on kind)\nkind delete cluster --name k8s-mtls\n```\n\n", "url": "https://wpnews.pro/news/zero-trust-workload-identity-in-kubernetes-with-spiffe-spire-and-cilium", "canonical_source": "https://www.freecodecamp.org/news/implement-zero-trust-workload-identity-in-kubernetes-with-spiffe-spire-and-cilium/", "published_at": "2026-07-08 07:16:32+00:00", "updated_at": "2026-07-08 07:29:51.828863+00:00", "lang": "en", "topics": ["ai-safety", "ai-infrastructure"], "entities": ["SPIFFE", "SPIRE", "Cilium", "Kubernetes", "CNCF", "eBPF"], "alternates": {"html": "https://wpnews.pro/news/zero-trust-workload-identity-in-kubernetes-with-spiffe-spire-and-cilium", "markdown": "https://wpnews.pro/news/zero-trust-workload-identity-in-kubernetes-with-spiffe-spire-and-cilium.md", "text": "https://wpnews.pro/news/zero-trust-workload-identity-in-kubernetes-with-spiffe-spire-and-cilium.txt", "jsonld": "https://wpnews.pro/news/zero-trust-workload-identity-in-kubernetes-with-spiffe-spire-and-cilium.jsonld"}}