Background – The All‑Too‑Common “TLS 1.3‑Only” Assumption
Many teams adopt TLS 1.3 because it offers forward secrecy, reduced handshake latency, and a clean cipher suite list. The assumption that “TLS 1.3 = zero‑trust” leads to a dangerous simplification: the transport layer is secured, but the identity of the client and the integrity of the endpoint remain unchecked. In a micro‑service mesh where services call each other dozens of times per second, this blind spot can be exploited by a compromised container, a rogue internal pod, or a man‑in‑the‑middle that has hijacked a DNS entry.
Why TLS 1.3‑Only Is Not Enough
1. No Mutual Authentication – TLS 1.3 validates the server certificate but does not require a client certificate. An attacker who can launch a request from inside the network can impersonate any service that trusts the CA.
2. Certificate Revocation is Weak – Online Certificate Status Protocol (OCSP) stapling is optional, and many cloud‑native runtimes ignore revocation checks to avoid latency spikes. A stolen private key can stay valid for the lifetime of the certificate.
3. Lack of Cryptographic Binding to Service Identity – Without explicit binding, a service may accept any certificate signed by the same internal CA, even if the certificate was issued for a different role.
The hidden cost of this shortcut is a false sense of security that collapses under a single compromised pod.
Secure Alternative – Mutual‑TLS (mTLS) with Automated Rotation
The remedy is to layer mutual authentication on top of TLS 1.3, enforce strict certificate pinning, and automate renewal. The following tutorial walks through a minimal but production‑ready setup using OpenSSL for certificate generation, a simple Go micro‑service that validates client certificates, and a GitHub Actions workflow that rotates certificates every 30 days.
# Generate a private key for the internal CA
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out ca.key
# Self‑sign the CA certificate (10‑year validity)
openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 \
-subj "/C=US/ST=CA/O=Acme Corp/OU=Security/CN=Acme Internal CA" \
-out ca.crt
The CA will sign all service certificates. Store ca.key and ca.crt in a vault that only the CI system can read.
# Create a key for the “payment” service
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out payment.key
# Create a CSR with a distinct Common Name
openssl req -new -key payment.key \
-subj "/C=US/ST=CA/O=Acme Corp/OU=Payments/CN=payment.service.internal" \
-out payment.csr
# Sign the CSR with the internal CA (90‑day validity)
openssl x509 -req -in payment.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out payment.crt -days 90 -sha256 -extfile <(printf "subjectAltName=DNS:payment.service.internal")
Repeat the process for every micro‑service (e.g., order, inventory). The resulting .crt and .key files are the identity material each container will mount at runtime.
Implementing mTLS in a Go Service
Below is a compact Go server that enforces client certificate verification. It rejects any request where the client certificate cannot be validated against the internal CA.
package main
import (
"crypto/tls"
"crypto/x509"
"io/ioutil"
"log"
"net/http"
)
func main() {
// Load server certificate
cert, err := tls.LoadX509KeyPair("/certs/payment.crt", "/certs/payment.key")
if err != nil {
log.Fatalf("failed to load server cert: %v", err)
}
// Load internal CA
caPEM, err := ioutil.ReadFile("/certs/ca.crt")
if err != nil {
log.Fatalf("failed to read CA cert: %v", err)
}
caPool := x509.NewCertPool()
if ok := caPool.AppendCertsFromPEM(caPEM); !ok {
log.Fatal("failed to append CA cert")
}
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
ClientCAs: caPool,
// Require and verify client certs
ClientAuth: tls.RequireAndVerifyClientCert,
MinVersion: tls.VersionTLS13,
}
server := &http.Server{
Addr: ":8443",
TLSConfig: tlsConfig,
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Authenticated request received"))
}),
}
log.Println("Starting mTLS server on :8443")
if err := server.ListenAndServeTLS("", ""); err != nil {
log.Fatalf("server error: %v", err)
}
}
Build the binary, copy the payment.crt, payment.key, and ca.crt into the container, and run it with the --network host flag in Kubernetes or Docker.
# Dockerfile example
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o /bin/payment .
FROM alpine:3.18
COPY --from=builder /bin/payment /usr/local/bin/payment
COPY certs/ /certs/
EXPOSE 8443
ENTRYPOINT ["/usr/local/bin/payment"]
The container now refuses any connection that does not present a valid client certificate signed by ca.crt. This eliminates the “TLS‑only” blind spot.
Automating Certificate Rotation with GitHub Actions
Manual renewal is error‑prone. The following workflow generates new service certificates, pushes them to a private repository, and triggers a rolling update of the affected deployments.
name: Rotate Service Certs
on:
schedule:
- cron: '0 0 */30 * *' # Every 30 days
workflow_dispatch:
jobs:
generate:
runs-on: ubuntu‑latest
steps:
- name: Checkout repo
uses: actions/checkout@v3
- name: Set up OpenSSL
run: sudo apt‑get update && sudo apt‑get install -y openssl
- name: Generate certs
env:
CA_KEY: ${{ secrets.CA_KEY }}
CA_CRT: ${{ secrets.CA_CRT }}
run: |
mkdir -p certs
# Example for payment service
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out certs/payment.key
openssl req -new -key certs/payment.key -subj "/CN=payment.service.internal" -out certs/payment.csr
echo "$CA_CRT" > ca.crt
echo "$CA_KEY" > ca.key
openssl x509 -req -in
♥ Enjoyed this article? Use the like banner at the top
of the page to let us know — you can like it more than once! Each
additional like from the same reader carries a little less weight than
the first, so our appreciation scores reflect genuine enthusiasm rather
than accidental clicks. Your feedback helps us understand which topics
resonate most.