Day 41 — Mutual TLS (mTLS)
Learning Objectives
- Implement zero-trust service authentication: both sides verify each other
- Extract client identity from certificates — the foundation of your service mesh
- Understand why mTLS > API keys for service-to-service auth
Key Concepts
Standard TLS (one-way):
Server proves its identity to client via certificate
Client is anonymous (authenticated by other means: API key, JWT, etc.)
mTLS (mutual):
Server proves its identity to client via certificate
Client ALSO proves its identity via certificate
Both signed by a shared CA → only authorized clients can connect
Zero Trust with mTLS:
No API keys to rotate, lose, or steal
Certificate expiry enforces rotation automatically
Identity is cryptographic, not a secret string
Used by: Istio, Linkerd, Cloudflare Access, Google BeyondCorp
Exercise: mTLS Server with Identity Extraction
package main
import (
"crypto/tls"
"crypto/x509"
"fmt"
"io/ioutil"
"net/http"
"os"
)
func loadCACertPool(caFile string) (*x509.CertPool, error) {
caPEM, err := os.ReadFile(caFile)
if err != nil { return nil, err }
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caPEM) {
return nil, fmt.Errorf("failed to parse CA cert from %s", caFile)
}
return pool, nil
}
func buildMTLSServerConfig(certFile, keyFile, caFile string) (*tls.Config, error) {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil { return nil, err }
caPool, err := loadCACertPool(caFile)
if err != nil { return nil, err }
return &tls.Config{
Certificates: []tls.Certificate{cert},
ClientCAs: caPool,
ClientAuth: tls.RequireAndVerifyClientCert, // enforce mTLS
MinVersion: tls.VersionTLS12,
// Called after mTLS handshake — extract client identity
VerifyPeerCertificate: func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
if len(verifiedChains) == 0 {
return fmt.Errorf("no verified certificate chain")
}
// Additional custom checks beyond standard verification
clientCert := verifiedChains[0][0]
fmt.Printf("[mTLS] client identity: %s\n", clientCert.Subject.CommonName)
return nil
},
}, nil
}
func clientIdentityFromRequest(r *http.Request) string {
if r.TLS == nil || len(r.TLS.VerifiedChains) == 0 {
return ""
}
return r.TLS.VerifiedChains[0][0].Subject.CommonName
}
func main() {
tlsConfig, err := buildMTLSServerConfig("server.crt", "server.key", "ca.crt")
if err != nil {
fmt.Println("TLS config:", err)
return
}
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
identity := clientIdentityFromRequest(r)
if identity == "" {
http.Error(w, "missing client certificate", http.StatusForbidden)
return
}
// Inject identity into response for downstream services
w.Header().Set("X-Client-Identity", identity)
fmt.Fprintf(w, `{"authenticated_as":%q,"path":%q}`, identity, r.URL.Path)
})
srv := &http.Server{
Addr: ":8443",
Handler: mux,
TLSConfig: tlsConfig,
}
fmt.Println("mTLS server on :8443")
fmt.Println("Test: curl --cert client.crt --key client.key --cacert ca.crt https://localhost:8443/")
srv.ListenAndServeTLS("", "") // certs already in TLSConfig
}
Test:
# First generate certs (Day 40)
go run ../day40-cert-gen/main.go
# Test with valid client cert — should succeed
curl --cert client.crt --key client.key --cacert ca.crt https://localhost:8443/
# Test without client cert — should fail (403)
curl --cacert ca.crt https://localhost:8443/
# Test with wrong CA client cert — should fail (TLS handshake error)
Real-World Context
mTLS is how Google's zero-trust BeyondCorp works. Instead of VPN (trust everything inside the network), each service has a cert. Compromising one service doesn't compromise others — the attacker gets that service's cert, not network-level access. Istio automates mTLS between all pods in Kubernetes.