Secure Microservice Communication Patterns
In a microservice architecture, the network between services is as much an attack surface as the internet-facing edge. A compromised service can pivot to any other service on the same network unless you enforce authentication and authorization between them. This guide covers four patterns from basic to production-grade zero trust.
The Problem: Flat Internal Networks
In a default Kubernetes cluster or Docker network, all pods can reach all other pods on any port. If attacker code runs in any service — via a supply chain attack, SSRF, or RCE — they have unfettered access to every database, internal API, and message queue.
Without isolation:
[Auth Service] ──can reach──> [Payment Service] ──can reach──> [User DB]
──can reach──> [Config Service]
[Compromised Service] ──can reach──> ALL of the above
Pattern 1: mTLS (Mutual TLS)
mTLS requires both client and server to present certificates. A service without a valid certificate cannot connect to another service — even within the same cluster.
Manual mTLS with Go
// server.go — service requiring client certificate
package main
import (
"crypto/tls"
"crypto/x509"
"net/http"
"os"
"log"
)
func main() {
// Load CA that signed client certs
caCert, _ := os.ReadFile("/certs/ca.crt")
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
tlsConfig := &tls.Config{
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: caCertPool,
MinVersion: tls.VersionTLS13,
}
server := &http.Server{
Addr: ":8443",
TLSConfig: tlsConfig,
Handler: http.HandlerFunc(handler),
}
log.Fatal(server.ListenAndServeTLS("/certs/server.crt", "/certs/server.key"))
}
func handler(w http.ResponseWriter, r *http.Request) {
// Client identity from verified certificate
clientCN := r.TLS.PeerCertificates[0].Subject.CommonName
log.Printf("Authenticated client: %s", clientCN)
w.Write([]byte("OK"))
}
// client.go — service presenting its certificate
package main
import (
"crypto/tls"
"crypto/x509"
"net/http"
"os"
)
func newMTLSClient() *http.Client {
cert, _ := tls.LoadX509KeyPair("/certs/client.crt", "/certs/client.key")
caCert, _ := os.ReadFile("/certs/ca.crt")
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
return &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: caCertPool,
MinVersion: tls.VersionTLS13,
},
},
}
}
Certificate issuance for mTLS: each service needs a cert. At scale, automate with cert-manager (Kubernetes) or SPIFFE/SPIRE.
Pattern 2: SPIFFE/SPIRE (Identity Framework)
SPIFFE (Secure Production Identity Framework for Everyone) assigns cryptographic identities to workloads, not to machines. A service running on pod X gets a SPIFFE ID like spiffe://cluster.local/ns/payments/sa/payment-service.
SPIRE is the reference implementation. It issues X.509 SVIDs (SPIFFE Verifiable Identity Documents) to workloads that automatically rotate every hour.
# Install SPIRE server and agent on Kubernetes
kubectl apply -f https://github.com/spiffe/spire/releases/latest/download/spire-server.yaml
kubectl apply -f https://github.com/spiffe/spire/releases/latest/download/spire-agent.yaml
# Register a workload
kubectl exec -n spire spire-server-0 -- \
spire-server entry create \
-spiffeID spiffe://example.org/ns/default/sa/payment-service \
-parentID spiffe://example.org/ns/spire/sa/spire-agent \
-selector k8s:ns:default \
-selector k8s:sa:payment-service
Workloads fetch their SVID via the SPIFFE Workload API (Unix socket):
import "github.com/spiffe/go-spiffe/v2/workloadapi"
func getX509Source() *workloadapi.X509Source {
ctx := context.Background()
source, err := workloadapi.NewX509Source(ctx)
if err != nil {
log.Fatal(err)
}
return source
}
// Use SPIFFE-aware TLS — SVID rotates automatically
tlsConfig := tlsconfig.MTLSServerConfig(source, source,
tlsconfig.AuthorizeID(spiffeid.RequireIDFromString(
"spiffe://example.org/ns/default/sa/auth-service")))
Pattern 3: Service Mesh (Istio/Linkerd)
A service mesh injects a sidecar proxy (Envoy for Istio, ultra-lightweight proxy for Linkerd) into every pod. The sidecar handles mTLS transparently — application code does not need to implement TLS at all.
Istio setup:
# Install Istio
curl -L https://istio.io/downloadIstio | ISTIO_VERSION=1.21.0 sh -
istioctl install --set profile=default -y
# Enable mTLS globally (STRICT = reject plaintext)
kubectl apply -f - <<'EOF'
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
name: default
namespace: istio-system
spec:
mtls:
mode: STRICT
EOF
Authorization Policy — restrict which services can call which:
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
name: payment-service-policy
namespace: default
spec:
selector:
matchLabels:
app: payment-service
rules:
- from:
- source:
principals:
# Only auth-service and order-service can call payment-service
- "cluster.local/ns/default/sa/auth-service"
- "cluster.local/ns/default/sa/order-service"
to:
- operation:
methods: ["POST"]
paths: ["/payment/*"]
Pattern 4: Service-to-Service JWT
When mTLS infrastructure is too heavy, use short-lived JWTs for service identity. Each service has an asymmetric key pair; it signs JWTs and presents them to downstream services.
# service_auth.py — shared library for service-to-service JWT
import jwt, time, httpx
from pathlib import Path
from functools import lru_cache
SERVICE_NAME = "auth-service"
PRIVATE_KEY = Path("/run/secrets/service_private_key.pem").read_text()
PUBLIC_KEYS_URL = "http://internal-jwks:8080/.well-known/jwks.json"
def create_service_token(target_service: str) -> str:
now = int(time.time())
return jwt.encode({
"iss": SERVICE_NAME,
"sub": SERVICE_NAME,
"aud": target_service,
"iat": now,
"exp": now + 300, # 5-minute TTL
}, PRIVATE_KEY, algorithm="RS256")
@lru_cache(maxsize=1, typed=False)
def get_public_keys() -> dict:
return httpx.get(PUBLIC_KEYS_URL).json()
def verify_service_token(token: str, expected_issuer: str) -> dict:
jwks = get_public_keys()
# Find the key matching the token's kid header
header = jwt.get_unverified_header(token)
key = next(k for k in jwks["keys"] if k["kid"] == header["kid"])
return jwt.decode(
token,
jwt.algorithms.RSAAlgorithm.from_jwk(key),
algorithms=["RS256"],
audience=SERVICE_NAME,
issuer=expected_issuer,
)
Kubernetes Network Policy
Regardless of which auth pattern you use, enforce network policy to restrict traffic at the pod level:
# network-policy.yaml — payment-service only accepts from auth and order
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: payment-service-netpol
namespace: default
spec:
podSelector:
matchLabels:
app: payment-service
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector:
matchLabels:
app: auth-service
- podSelector:
matchLabels:
app: order-service
ports:
- protocol: TCP
port: 8443
egress:
- to:
- podSelector:
matchLabels:
app: user-database
ports:
- protocol: TCP
port: 5432
- ports:
- protocol: UDP
port: 53 # DNS
Network policy is enforced by the CNI plugin (Calico, Cilium, Weave Net). It provides defense in depth even if the service-layer auth is bypassed.
Choosing the Right Pattern
| Scenario | Pattern |
|---|---|
| Greenfield Kubernetes cluster | Istio or Linkerd (transparent mTLS) |
| Existing services, can’t add sidecars | Manual mTLS or service-to-service JWT |
| Strict identity rotation requirements | SPIFFE/SPIRE |
| Legacy non-containerized services | mTLS with cert-manager or Vault PKI |
| Basic hardening for small teams | Network Policy + service-to-service JWT |
Start with Network Policy for immediate blast-radius reduction, then layer in mTLS as the authoritative authentication mechanism.
Related Reading
- How to Set Up Wazuh SIEM for Small Teams
- Secure JWT Implementation Best Practices
- Secure API Gateway Setup with Kong
Built by theluckystrike — More at zovo.one