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
- Secure Microservice Communication Patterns
- Secure JWT Implementation Best Practices
- Secure Environment Variable Management
Built by theluckystrike. More at zovo.one