The most surprising thing about TLS and mutual authentication in Go is that you’re often better off not using the standard http.Server’s TLSConfig for mutual TLS (mTLS) if you need granular control over client certificate validation.
Let’s see how this plays out in practice. Imagine we have a simple Go HTTP server that needs to accept TLS connections and, critically, verify the client’s certificate.
package main
import (
"crypto/tls"
"crypto/x509"
"fmt"
"io/ioutil"
"log"
"net/http"
)
func helloHandler(w http.ResponseWriter, r *http.Request) {
// In mTLS, r.TLS.PeerCertificates will contain the client's certificate chain
if len(r.TLS.PeerCertificates) == 0 {
http.Error(w, "Client certificate is required", http.StatusUnauthorized)
return
}
clientCert := r.TLS.PeerCertificates[0]
log.Printf("Client certificate issued by: %s", clientCert.Issuer)
fmt.Fprintf(w, "Hello, %s!", clientCert.Subject.CommonName)
}
func main() {
// Load server certificate and key
cert, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
log.Fatalf("Failed to load server key pair: %v", err)
}
// Load the CA certificate that signed the client certificates
caCert, err := ioutil.ReadFile("ca.crt")
if err != nil {
log.Fatalf("Failed to read CA certificate: %v", err)
}
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
// Configure TLS
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
ClientCAs: caCertPool, // Pool of CAs that signed client certs
ClientAuth: tls.RequireAndVerifyClientCert, // Request and verify client cert
}
// Create a custom HTTP server with the TLS config
server := &http.Server{
Addr: ":8443",
Handler: http.HandlerFunc(helloHandler),
TLSConfig: tlsConfig,
// Note: For full mTLS control, often you'd bypass http.Server's TLSConfig
// and handle TLS handshake directly with net.Listen.
}
log.Println("Starting HTTPS server on :8443 with mTLS enabled")
err = server.ListenAndServeTLS("", "") // Pass empty strings as certFile/keyFile to use TLSConfig
if err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}
In this example, tls.Config is central. Certificates holds our server’s identity. ClientCAs tells the server which Certificate Authorities (CAs) it trusts to issue client certificates. ClientAuth: tls.RequireAndVerifyClientCert is the magic switch that says, "I need a client certificate, and I will verify it against my ClientCAs."
When a client connects, the Go TLS handshake will:
- The server presents its certificate.
- The client verifies the server’s certificate (using its own trust store).
- The client presents its certificate.
- The server checks if the client’s certificate was signed by any of the CAs in
ClientCAs. - If verification passes, the handshake completes, and the client’s certificate chain is available in
r.TLS.PeerCertificates.
The problem is that tls.Config.ClientAuth is a bit coarse-grained. tls.RequireAndVerifyClientCert means "verify against ClientCAs." If you need to do more than just check the signature of the issuing CA—say, check the certificate’s subject name, its validity period again, or even look up its status in a CRL or OCSP responder—you have to dig deeper.
What most people don’t realize is that the tls.Config struct has a VerifyPeerCertificate field. This is a callback function that gets invoked after the basic CA chain validation has occurred. If you set ClientAuth to tls.NoClientCert or tls.RequestClientCert, the handshake won’t fail if no client cert is presented or if it fails basic CA validation. However, if you provide a VerifyPeerCertificate function, it will be called for every client certificate presented, regardless of the ClientAuth setting (as long as a client cert was presented). This gives you the ultimate control.
To implement more granular client certificate validation, you would typically set ClientAuth to tls.RequestClientCert (or even tls.NoClientCert if you want to optionally accept client certs) and then populate tlsConfig.VerifyPeerCertificate. Inside this callback, you get the rawCerts (the DER-encoded certificates) and verifiedChains. You can then parse rawCerts into x509.Certificate objects and perform your custom checks. If any check fails, you return an error, which will cause the TLS handshake to abort.
This VerifyPeerCertificate callback is the escape hatch for complex mTLS scenarios, allowing you to enforce policies beyond simple CA trust, like checking specific Subject Alternative Names (SANs) or ensuring certificates are not revoked.
The next hurdle you’ll likely face is managing the lifecycle of these certificates, especially in dynamic environments where they need to be rotated or revoked without restarting the server.