DevOps

Building a Zero-Trust Microservices Network on Kubernetes: Policies, mTLS, and Identity Without a Service Mesh

Ruslan Ismailov Published 12 min read
B

Introduction: Why Zero-Trust Became a Necessity in 2026

The zero-trust security model is built on the principle of "trust no one by default" — neither external requests nor internal services within the cluster. By 2026, this philosophy has evolved from an architectural curiosity into a de facto standard for production Kubernetes environments. The reasons are straightforward: horizontal scaling of microservices expands the attack surface, and compromising a single pod without proper isolation opens a path to the entire infrastructure.

Many teams immediately reach for Istio or Linkerd, assuming zero-trust is impossible without a full Service Mesh. This is a misconception. Kubernetes provides sufficient primitives — NetworkPolicy, RBAC, ServiceAccount — while the open standard SPIFFE and its implementation SPIRE handle identity without an additional data plane. Mutual TLS authentication can be implemented directly in Go service code. This article examines each of these layers in detail, with YAML and Go code examples.

Problems with Traditional Perimeter Security in Kubernetes

The classic "secure perimeter" approach assumes that everything inside the network is trusted. In Kubernetes, this means any pod in the cluster can communicate with any other pod via Service DNS by default — with no restrictions unless explicitly configured otherwise.

The consequences are clear:

  • A compromised payment-service pod can directly reach user-db without any verification.
  • The absence of mutual TLS means a service cannot be certain who it is actually communicating with — a legitimate service or a man-in-the-middle attacker.
  • Lateral movement after compromising a single component becomes trivial.

Zero-trust addresses these issues through three orthogonal mechanisms: network isolation, cryptographic identity, and auditing of every interaction.

NetworkPolicy: Traffic Isolation at the Namespace and Pod Level

NetworkPolicy is a Kubernetes resource that defines ingress and egress traffic rules for a set of pods. Importantly, NetworkPolicy only works if a CNI plugin with policy support is installed in the cluster (Calico, Cilium, Weave Net). Flannel in its default configuration does not enforce NetworkPolicy.

A "deny-all by default" strategy is the first step toward zero-trust. Create a policy that blocks all incoming traffic for every pod in the payments namespace:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-ingress
  namespace: payments
spec:
  podSelector: {}
  policyTypes:
    - Ingress

Then explicitly allow only the required paths. For example, order-service from the orders namespace should be able to reach payment-service in the payments namespace:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-order-to-payment
  namespace: payments
spec:
  podSelector:
    matchLabels:
      app: payment-service
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: orders
          podSelector:
            matchLabels:
              app: order-service
      ports:
        - protocol: TCP
          port: 8443

Note that we use port 8443 — already hinting at TLS. NetworkPolicy does not inspect traffic content; it operates at L3/L4. For L7 control, you need either a CNI with L7 support (Cilium with Hubble) or application-layer enforcement via mTLS.

Recommendations for managing NetworkPolicy in production:

  • Create default-deny policies for both ingress and egress in every namespace.
  • Explicitly allow DNS resolution (kube-dns, port 53 UDP/TCP).
  • Use separate labels for different pod roles (role: api, role: worker).
  • Version-control NetworkPolicy in Git as part of your IaC.

SPIFFE/SPIRE: Issuing and Rotating Service Identities

SPIFFE (Secure Production Identity Framework for Everyone) is an open standard that defines the format of cryptographic identities for workloads. An identity takes the form of a URI: spiffe://trust-domain/path. For example: spiffe://cluster.local/ns/payments/sa/payment-service.

SPIRE is the reference implementation of SPIFFE, consisting of two components:

  • SPIRE Server — a central CA that issues SVIDs (SPIFFE Verifiable Identity Documents) as x.509 certificates.
  • SPIRE Agent — a DaemonSet on each node that attests pods via the Kubernetes API and delivers SVIDs through a Unix socket.

Deploying SPIRE in a cluster begins with installing the CRDs and the Server deployment. A minimal ConfigMap for the SPIRE Server:

apiVersion: v1
kind: ConfigMap
metadata:
  name: spire-server
  namespace: spire
data:
  server.conf: |
    server {
      bind_address = "0.0.0.0"
      bind_port = "8081"
      trust_domain = "cluster.local"
      data_dir = "/run/spire/server/data"
      log_level = "INFO"
      ca_ttl = "24h"
      default_x509_svid_ttl = "1h"
    }
    plugins {
      DataStore "sql" {
        plugin_data {
          database_type = "sqlite3"
          connection_string = "/run/spire/server/data/datastore.sqlite3"
        }
      }
      NodeAttestor "k8s_psat" {
        plugin_data {
          clusters = {
            "my-cluster" = {
              service_account_allow_list = ["spire:spire-agent"]
            }
          }
        }
      }
      KeyManager "disk" {
        plugin_data {
          keys_path = "/run/spire/server/data/keys.json"
        }
      }
    }

The SPIRE Agent registers on a node via a Projected Service Account Token (PSAT) — an attestation mechanism built into Kubernetes. After successful attestation, the agent gains access to the Workload API and distributes SVIDs to pods through the Unix socket at /run/spire/sockets/agent.sock.

A workload registration entry is created with the following command:

kubectl exec -n spire spire-server-0 -- \
  spire-server entry create \
  -spiffeID spiffe://cluster.local/ns/payments/sa/payment-service \
  -parentID spiffe://cluster.local/spire/agent/k8s_psat/my-cluster/NODE_UID \
  -selector k8s:ns:payments \
  -selector k8s:sa:payment-service

SVIDs are automatically rotated 5 minutes before their TTL expires. A service receives a new certificate without restarting — this is critical for long-running Go services.

mTLS Between Go Services: Implementation Without a Service Mesh

After obtaining an SVID from the SPIRE Agent via the Workload API, a Go service uses it to establish mutual TLS authentication with other services. The github.com/spiffe/go-spiffe/v2 library abstracts Workload API interaction and provides ready-to-use tls.Config objects.

Example Go code for a server with mTLS via SPIFFE:

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"

	"github.com/spiffe/go-spiffe/v2/spiffeid"
	"github.com/spiffe/go-spiffe/v2/spiffetls"
	"github.com/spiffe/go-spiffe/v2/spiffetls/tlsconfig"
	"github.com/spiffe/go-spiffe/v2/workloadapi"
)

func main() {
	ctx := context.Background()

	// Connect to the SPIRE Agent via Workload API
	client, err := workloadapi.New(ctx,
		workloadapi.WithAddr("unix:///run/spire/sockets/agent.sock"),
	)
	if err != nil {
		log.Fatalf("unable to create workload API client: %v", err)
	}
	defer client.Close()

	// X.509 SVID source with automatic rotation
	source, err := workloadapi.NewX509Source(ctx,
		workloadapi.WithClient(client),
	)
	if err != nil {
		log.Fatalf("unable to create X509Source: %v", err)
	}
	defer source.Close()

	// Allow only order-service from our trust domain
	allowedID := spiffeid.RequireFromString(
		"spiffe://cluster.local/ns/orders/sa/order-service",
	)

	tlsCfg := tlsconfig.MTLSServerConfig(
		source,
		source,
		tlsconfig.AuthorizeID(allowedID),
	)

	server := &http.Server{
		Addr:      ":8443",
		TLSConfig: tlsCfg,
		Handler:   http.HandlerFunc(paymentHandler),
	}

	log.Println("payment-service listening on :8443 with mTLS")
	if err := server.ListenAndServeTLS("", ""); err != nil {
		log.Fatalf("server error: %v", err)
	}
}

func paymentHandler(w http.ResponseWriter, r *http.Request) {
	// The client's SPIFFE ID is available via TLS peer certificates
	if len(r.TLS.PeerCertificates) > 0 {
		log.Printf("authenticated caller SPIFFE ID: %s",
			r.TLS.PeerCertificates[0].URIs[0])
	}
	fmt.Fprintln(w, `{"status": "ok"}`)
}

Client code for order-service calling payment-service:

package main

import (
	"context"
	"io"
	"log"
	"net/http"

	"github.com/spiffe/go-spiffe/v2/spiffeid"
	"github.com/spiffe/go-spiffe/v2/spiffetls/tlsconfig"
	"github.com/spiffe/go-spiffe/v2/workloadapi"
)

func main() {
	ctx := context.Background()

	source, err := workloadapi.NewX509Source(ctx,
		workloadapi.WithClientOptions(
			workloadapi.WithAddr("unix:///run/spire/sockets/agent.sock"),
		),
	)
	if err != nil {
		log.Fatalf("X509Source error: %v", err)
	}
	defer source.Close()

	serverID := spiffeid.RequireFromString(
		"spiffe://cluster.local/ns/payments/sa/payment-service",
	)

	tlsCfg := tlsconfig.MTLSClientConfig(
		source,
		source,
		tlsconfig.AuthorizeID(serverID),
	)

	client := &http.Client{
		Transport: &http.Transport{TLSClientConfig: tlsCfg},
	}

	resp, err := client.Get("https://payment-service.payments.svc.cluster.local:8443/charge")
	if err != nil {
		log.Fatalf("request failed: %v", err)
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)
	log.Printf("response: %s", body)
}

Key implementation details:

  • The tlsconfig.AuthorizeID functions verify the peer's SPIFFE ID — if it doesn't match the expected ID, the TLS handshake fails.
  • X509Source automatically renews certificates in the background by watching the Workload API.
  • The SPIRE Agent socket is mounted into the pod via a hostPath volume or CSI driver.

Volume mount for the pod:

volumes:
  - name: spire-agent-socket
    hostPath:
      path: /run/spire/sockets
      type: Directory
containers:
  - name: payment-service
    image: payment-service:latest
    volumeMounts:
      - name: spire-agent-socket
        mountPath: /run/spire/sockets
        readOnly: true

Auditing and Logging Security Events

Zero-trust without auditing is only half the job. Every significant event must be recorded: who called whom, when, and with what result.

At the Kubernetes level, enable the Audit Policy for the API Server. An example policy that logs NetworkPolicy changes and secret access:

apiVersion: audit.k8s.io/v1
kind: Policy
rules:
  - level: RequestResponse
    resources:
      - group: "networking.k8s.io"
        resources: ["networkpolicies"]
  - level: Metadata
    resources:
      - group: ""
        resources: ["secrets", "serviceaccounts"]
  - level: None
    users: ["system:nodes"]
    verbs: ["get", "list", "watch"]

At the application level, every Go service should log the SPIFFE ID of incoming connections. Use structured logging (slog or zap) and ship logs to a centralized store (Loki, Elasticsearch). Example middleware for an HTTP server:

func auditMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		callerID := "unknown"
		if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 {
			for _, uri := range r.TLS.PeerCertificates[0].URIs {
				callerID = uri.String()
				break
			}
		}
		log.Printf(`{"event":"request","caller":"%s","path":"%s","method":"%s"}`,
			callerID, r.URL.Path, r.Method)
		next.ServeHTTP(w, r)
	})
}

Additionally, consider integrating Falco — a runtime security tool capable of detecting anomalous syscall patterns and network connections directly within pods.

Practical Example: Secured Communication Between Three Microservices

Consider the following scenario: api-gateway (namespace gateway) → order-service (namespace orders) → payment-service (namespace payments). Every hop must be protected by mTLS with SPIFFE ID verification.

Identity architecture:

  • spiffe://cluster.local/ns/gateway/sa/api-gateway
  • spiffe://cluster.local/ns/orders/sa/order-service
  • spiffe://cluster.local/ns/payments/sa/payment-service

The NetworkPolicy for order-service allows incoming traffic only from api-gateway:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-gateway-to-orders
  namespace: orders
spec:
  podSelector:
    matchLabels:
      app: order-service
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: gateway
          podSelector:
            matchLabels:
              app: api-gateway
      ports:
        - protocol: TCP
          port: 8443
  policyTypes:
    - Ingress

At the application level, order-service additionally verifies that the client's SPIFFE ID belongs specifically to api-gateway. Even if NetworkPolicy is bypassed (for example, via a compromised node), mTLS with identity verification will prevent an unauthorized service from establishing a connection.

The defense chain looks like this:

  1. NetworkPolicy blocks unwanted L3/L4 traffic before packets reach the pod.
  2. The TLS handshake with certificate verification provides encryption and authentication at L4.
  3. SPIFFE ID verification in Go code enforces authorization at the application layer.
  4. The audit log records every successful and failed interaction.

This multi-layered defense implements the principle of defense-in-depth without a single point of failure.

Conclusions

Zero-trust security in Kubernetes is not a product you can "install and forget." It is an architectural approach implemented through a combination of mechanisms:

  • NetworkPolicy provides network isolation at L3/L4 without modifying service code.
  • SPIFFE/SPIRE give every service a cryptographically verified identity with automatic rotation.
  • mTLS in Go with SPIFFE ID verification enforces authorization at the application layer.
  • Auditing and logging provide visibility and forensic capabilities.

The described stack does not require Istio or Linkerd and integrates well into existing Kubernetes clusters. The absence of a Service Mesh means less operational complexity, fewer components that can fail, and direct control over every aspect of security. For teams that want a complete understanding of their security model, this is often a better choice than an opaque sidecar proxy.

Start small: introduce a default-deny NetworkPolicy in one namespace, deploy SPIRE, and add mTLS to a single Go service. This incremental approach allows the team to build expertise without risking production stability.

Technologies

Tags

Ruslan Ismailov

Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →