When Go services on the same internal network identify each other only by IP address or a shared header, any compromised container can call any other service without restriction. Mutual TLS (mTLS) changes the model: both sides must present a valid certificate before the connection is established, and neither accepts a certificate it did not sign off on. Go's crypto/tls package handles all of this natively; getting mTLS working between two services is roughly 30 lines of configuration per side.
Why mTLS instead of API keys
Regular TLS authenticates the server to the client — your browser checks it is talking to the real bank. mTLS adds the reverse: the server also authenticates the client. In a microservices environment, every service call carries verifiable cryptographic proof of identity without adding anything to the HTTP request itself.
The comparison with API keys is worth being explicit about:
- An API key is a shared secret. A key stored in an environment variable on a compromised container leaks; rotating every service that holds it is an incident, not a routine.
- A client certificate proves possession of a private key that never leaves the service. Compromise requires the private key itself, not just a value that can be copied from memory or logs.
- Certificate expiry and revocation are first-class mechanisms. You can enforce 30-day lifetimes and let automation handle renewal; there is no standard equivalent for API keys.
The operational cost is running a certificate authority. For internal traffic a self-signed root CA is usually enough; for production you will want Vault PKI or cert-manager to automate issuance and rotation.
Generating the CA and certificates
Three artifacts: root CA, server certificate, client certificate. Signing all three with the same CA is what makes the mutual verification work — both sides trust the same authority.
# Root CA (long-lived — protect this key)
openssl req -x509 -newkey rsa:4096 -days 3650 -nodes \
-keyout ca.key -out ca.crt -subj "/CN=internal-ca"
# Server certificate for the orders service
openssl req -newkey rsa:4096 -nodes \
-keyout server.key -out server.csr -subj "/CN=orders-service"
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key \
-CAcreateserial -out server.crt -days 90
# Client certificate for the payments service
openssl req -newkey rsa:4096 -nodes \
-keyout client.key -out client.csr -subj "/CN=payments-service"
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key \
-CAcreateserial -out client.crt -days 90
Set leaf certificate validity to 90 days or less and automate renewal at two-thirds of that window. The CN value (payments-service, orders-service) becomes the service identity you will use for authorization downstream.
Server and client configuration in Go
Server side: the key difference from a standard HTTPS server is ClientAuth: tls.RequireAndVerifyClientCert and loading the CA pool to validate the certificate the client presents.
package main
import (
"crypto/tls"
"crypto/x509"
"log"
"net/http"
"os"
)
func main() {
caCert, _ := os.ReadFile("ca.crt")
caPool := x509.NewCertPool()
caPool.AppendCertsFromPEM(caCert)
serverCert, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
log.Fatal(err)
}
tlsCfg := &tls.Config{
Certificates: []tls.Certificate{serverCert},
ClientCAs: caPool,
ClientAuth: tls.RequireAndVerifyClientCert,
MinVersion: tls.VersionTLS13,
}
srv := &http.Server{
Addr: ":8443",
TLSConfig: tlsCfg,
Handler: http.HandlerFunc(handler),
}
log.Fatal(srv.ListenAndServeTLS("", ""))
}
func handler(w http.ResponseWriter, r *http.Request) {
// r.TLS.PeerCertificates[0] is the verified client cert
cn := r.TLS.PeerCertificates[0].Subject.CommonName
log.Printf("authenticated: %s %s %s", cn, r.Method, r.URL.Path)
w.Write([]byte("ok"))
}
MinVersion: tls.VersionTLS13 is worth enforcing. TLS 1.3 removes a collection of legacy cipher suites with known weaknesses and simplifies the handshake. Unless you have specific compatibility requirements with older clients, there is no reason to allow 1.2 for internal traffic.
Client side: symmetric. Load your own certificate and configure RootCAs so the client can verify the server.
func newMTLSClient(caFile, certFile, keyFile string) (*http.Client, error) {
ca, err := os.ReadFile(caFile)
if err != nil {
return nil, err
}
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(ca)
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, err
}
return &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: pool,
MinVersion: tls.VersionTLS13,
},
},
}, nil
}
Wrap this in a constructor function. When certificates rotate on a 90-day cycle, rebuilding the client is straightforward; a globally shared http.Client with embedded certs would require more care around thread safety and reload timing.
Authorization middleware and zero-downtime rotation
Verifying that a caller holds a valid certificate is authentication. Deciding what that caller is allowed to do is authorization, and they are separate concerns. A minimal middleware layer extracts the CN and checks it against a per-route allowlist:
func requireIdentity(allowed []string, next http.Handler) http.Handler {
permitted := make(map[string]bool)
for _, s := range allowed {
permitted[s] = true
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 {
http.Error(w, "missing client certificate", http.StatusUnauthorized)
return
}
cn := r.TLS.PeerCertificates[0].Subject.CommonName
if !permitted[cn] {
http.Error(w, "forbidden: "+cn, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
Register it as requireIdentity([]string{"payments-service"}, myHandler). Every rejected call logs the CN of the service that tried; every allowed call carries a verified identity for downstream logging and audit trails.
For zero-downtime certificate rotation, replace the static Certificates slice with a GetCertificate callback. The standard library calls this on every new TLS handshake, so updated cert files on disk take effect for new connections immediately — no process restart required:
tlsCfg := &tls.Config{
ClientCAs: caPool,
ClientAuth: tls.RequireAndVerifyClientCert,
MinVersion: tls.VersionTLS13,
GetCertificate: func(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
cert, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
return nil, err
}
return &cert, nil
},
}
Add a short-lived in-memory cache (30 seconds is enough) in front of LoadX509KeyPair if you expect high connection rates. The callback fires per-handshake; a filesystem read on every new TLS connection is measurable above a few hundred connections per second.
The takeaway
The configuration difference between a standard Go HTTPS server and a fully mutual TLS server is small — about 10 lines. The heavier investment is operational: choosing certificate lifetimes, wiring automated renewal into your deployment pipeline (Vault PKI, cert-manager, or a simple cron job for small setups), and deciding on a revocation strategy.
Once mTLS covers internal service-to-service traffic, you get a meaningful security boundary without a dedicated service mesh. A compromised service can only reach services for which it holds a valid certificate, and short-lived certs limit how long that access survives even if a private key is extracted.
The surrounding controls — cipher suite selection, network segmentation at the firewall level, and TLS hardening at load balancers — fit into a broader hardening posture. The free security hardening checklists at ayinedjimi-consultants.fr/checklists cover those points in detail, including specific configuration recommendations for Go service deployments.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
Top comments (0)