Last updated: March 22, 2026

How to Implement mTLS Between Microservices

Mutual TLS (mTLS) is the most reliable way to enforce identity between microservices. Both the client service and the server service present X.509 certificates; connections from services without a valid certificate are rejected at the TLS layer. before any application code runs. This guide implements mTLS at three levels: manual (for understanding), cert-manager (for Kubernetes), and Vault PKI (for multi-cloud).

Why mTLS Over API Keys

API keys can be stolen, logged, and shared. They don’t expire automatically and don’t identify which specific instance of a service is making a call. An X.509 certificate with a 24-hour TTL issued to a specific pod identity is far harder to abuse.

API Key threat model:
  Stolen key → attacker has permanent access until manual rotation

mTLS threat model:
  Stolen certificate → attacker has access until cert expires (24h typical)
  Certificate is tied to a specific service identity (SPIFFE ID or CN)
  Cannot be used from a different IP without a reissued cert

Part 1 - Manual mTLS Setup (Foundation)

Create a Private CA

Generate CA private key and certificate
openssl genrsa -out ca.key 4096
openssl req -new -x509 -key ca.key -out ca.crt -days 3650 \
  -subj "/O=MyOrg/CN=Internal Services CA"

Set secure permissions
chmod 600 ca.key

Issue Service Certificates

issue_cert() {
  local SERVICE="$1"
  # Private key
  openssl genrsa -out "${SERVICE}.key" 2048

  # CSR with SAN for service DNS name
  openssl req -new -key "${SERVICE}.key" -out "${SERVICE}.csr" \
    -subj "/O=MyOrg/CN=${SERVICE}" \
    -addext "subjectAltName=DNS:${SERVICE},DNS:${SERVICE}.default.svc.cluster.local"

  # Sign with CA. short 24h TTL forces rotation
  openssl x509 -req -in "${SERVICE}.csr" -CA ca.crt -CAkey ca.key \
    -CAcreateserial -out "${SERVICE}.crt" -days 1 \
    -extensions v3_req \
    -extfile <(printf "[v3_req]\nsubjectAltName=DNS:%s,DNS:%s.default.svc.cluster.local" \
                      "$SERVICE" "$SERVICE")
  rm "${SERVICE}.csr"
}

issue_cert "auth-service"
issue_cert "payment-service"
issue_cert "order-service"

Test mTLS Connection

Server (payment-service)
openssl s_server -accept 8443 \
  -cert payment-service.crt -key payment-service.key \
  -CAfile ca.crt \
  -Verify 1 \   # require client certificate
  -tls1_3 &

Client (auth-service connecting to payment-service)
openssl s_client -connect localhost:8443 \
  -cert auth-service.crt -key auth-service.key \
  -CAfile ca.crt \
  -tls1_3

Connection rejected without client cert:
openssl s_client -connect localhost:8443 -CAfile ca.crt
SSL_ERROR_HANDSHAKE_FAILURE_ALERT

Part 2 - cert-manager on Kubernetes

cert-manager automates certificate issuance, renewal, and distribution as Kubernetes Secrets.

Install cert-manager

kubectl apply -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml

Wait for pods
kubectl wait --for=condition=Ready pod -l app.kubernetes.io/instance=cert-manager \
  -n cert-manager --timeout=120s

Create an Internal CA Issuer

internal-ca.yaml. create CA secret and issuer
---
apiVersion: v1
kind: Secret
metadata:
  name: internal-ca-key-pair
  namespace: cert-manager
type: kubernetes.io/tls
data:
  tls.crt: <base64-encoded-ca.crt>
  tls.key: <base64-encoded-ca.key>
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: internal-ca
spec:
  ca:
    secretName: internal-ca-key-pair
Encode CA cert and key
kubectl create secret tls internal-ca-key-pair \
  --cert=ca.crt --key=ca.key -n cert-manager

kubectl apply -f internal-ca.yaml

Request Certificates for Each Service

auth-service-cert.yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: auth-service-tls
  namespace: default
spec:
  secretName: auth-service-tls
  duration: 24h           # 24-hour TTL
  renewBefore: 1h         # Renew 1h before expiry
  issuerRef:
    name: internal-ca
    kind: ClusterIssuer
  dnsNames:
    - auth-service
    - auth-service.default.svc.cluster.local
  usages:
    - client auth
    - server auth
kubectl apply -f auth-service-cert.yaml
kubectl apply -f payment-service-cert.yaml

Verify cert was issued
kubectl get certificate auth-service-tls
NAME               READY   SECRET             AGE
auth-service-tls   True    auth-service-tls   30s

Mount Certificates in Pods

Deployment snippet for auth-service
spec:
  template:
    spec:
      volumes:
        - name: tls-certs
          secret:
            secretName: auth-service-tls
        - name: ca-cert
          secret:
            secretName: internal-ca-key-pair
            items:
              - key: tls.crt
                path: ca.crt
      containers:
        - name: auth-service
          image: myorg/auth-service:latest
          volumeMounts:
            - name: tls-certs
              mountPath: /certs
              readOnly: true
            - name: ca-cert
              mountPath: /certs/ca
              readOnly: true
          env:
            - name: TLS_CERT_FILE
              value: /certs/tls.crt
            - name: TLS_KEY_FILE
              value: /certs/tls.key
            - name: TLS_CA_FILE
              value: /certs/ca/ca.crt

Part 3 - Vault PKI Secrets Engine

For multi-cloud or non-Kubernetes environments, HashiCorp Vault’s PKI engine issues short-lived certificates on demand.

Enable PKI secrets engine
vault secrets enable pki
vault secrets tune -max-lease-ttl=87600h pki   # 10-year CA

Generate internal CA
vault write pki/root/generate/internal \
  common_name="Internal Services CA" \
  ttl=87600h

Create intermediate CA for services
vault secrets enable -path=pki_int pki
vault write pki_int/intermediate/generate/internal \
  common_name="Services Intermediate CA"
  # Get CSR, sign with root, import back

Create role for service certs
vault write pki_int/roles/services \
  allowed_domains="svc.cluster.local,internal" \
  allow_subdomains=true \
  max_ttl=24h \
  require_cn=false \
  server_flag=true \
  client_flag=true

Issue a cert for a service (called at container startup via init container):

In an init container or entrypoint script
vault write pki_int/issue/services \
  common_name="payment-service.default.svc.cluster.local" \
  alt_names="payment-service" \
  ttl=24h \
  -format=json | python3 -c "
import json, sys, os
data = json.load(sys.stdin)['data']
open('/certs/service.crt', 'w').write(data['certificate'])
open('/certs/service.key', 'w').write(data['private_key'])
open('/certs/ca.crt', 'w').write(data['issuing_ca'])
os.chmod('/certs/service.key', 0o600)
print('Certificate issued, expires:', data['expiration'])
"

Part 4 - Verify mTLS in Production

Check that a service rejects connections without client cert
kubectl exec -it debug-pod -- \
  curl -v --cacert /certs/ca.crt \
  https://payment-service.default.svc.cluster.local:8443/health
Should fail - "SSL peer certificate or SSH remote key was not OK"

Verify with client cert
kubectl exec -it debug-pod -- \
  curl -v \
  --cacert /certs/ca.crt \
  --cert /certs/tls.crt \
  --key /certs/tls.key \
  https://payment-service.default.svc.cluster.local:8443/health
Should succeed - HTTP 200

Check certificate TTL
echo | openssl s_client -connect payment-service:8443 \
  -CAfile /certs/ca.crt \
  -cert /certs/tls.crt \
  -key /certs/tls.key 2>/dev/null \
  | openssl x509 -noout -dates

Automating Certificate Rotation

If using cert-manager - automatic (watches Secret, triggers renewal)
If using Vault - use Vault Agent with auto-renew

Vault Agent config for automatic renewal
cat > /etc/vault-agent.hcl <<'EOF'
auto_auth {
  method "kubernetes" {
    mount_path = "auth/kubernetes"
    config = { role = "payment-service" }
  }
}

template {
  source      = "/etc/tls.tmpl"
  destination = "/certs/service.crt"
  command     = "systemctl reload payment-service"
  perms       = 0644
}
EOF

Template file for certificate
{{- with secret "pki_int/issue/services" "common_name=payment-service" "ttl=24h" -}}
{{ .Data.certificate }}
{{- end }}

Related Reading


Built by theluckystrike. More at zovo.one