In this blog post, we will explore how to establish Mutual TLS (mTLS) connections using Go's built-in HTTP server and client to implement secure endpoints with certificate-based authentication.
X.509 and mTLS ¶
X.509 is a standard that defines the format of public key certificates used in various network protocols, including TLS, to provide secure connections between clients and servers. An X.509 certificate contains the public key of the entity it represents, along with information about the entity and the digital signature of a trusted certificate authority (CA).
With TLS, only the server presents its certificate to the client, so only the client verifies the server's identity. With mTLS, the server also requests a certificate from the client, allowing both parties to authenticate each other.
Benefits: ¶
- Zero-Trust Architecture: No entity is trusted by default, and verification is required at every step.
- Stronger Authentication: The use of client certificates provides a stronger form of authentication compared to passwords or API keys.
- Enhanced Protection Against Attacks: The mutual authentication process makes it more difficult for attackers to intercept or alter communications between the client and server.
Downsides ¶
While mTLS provides strong security benefits, it also comes with several challenges:
- Certificate Management Complexity: The management of client and server certificates can be cumbersome, requiring robust processes for issuance, renewal, and revocation.
- Performance Overhead: The additional cryptographic operations and handshake steps can introduce latency and increase resource consumption.
- Operational Complexity: The setup and management of mTLS can be more complex than traditional TLS, requiring additional configuration and maintenance efforts.
- Cost: Adding mTLS can increase infrastructure costs, as organizations may need to invest in additional hardware, software, and personnel to manage the certificate lifecycle.
Despite these challenges, many organizations find that the security benefits of mTLS outweigh the operational complexity, especially in zero-trust architectures and high-security environments.
Setting up Keys and Certificates ¶
The following Go example requires a set of certificates and keys for mTLS authentication. I use task to run all the necessary steps. For setting up the infrastructure, the task requires the openSSL command-line tool.
The following task sets up the certificate authority (CA) and generates the necessary certificates and keys. Note that while this example uses the same CA for both client and server certificates, you can use different CAs.
generate-ca:
cmds:
- echo "Creating Certificate Authority (CA) with {{.CRYPTO_ALGORITHM}}..."
- openssl genpkey -algorithm {{.CRYPTO_ALGORITHM}} -out ca-key.pem
- cmd: openssl req -new -x509 -key ca-key.pem -out ca-cert.pem -days {{.VALIDITY_DAYS}} -subj "/C=CH/ST=BE/L=Bern/O=Demo AG/OU=IT Department/CN=Demo CA"
platforms: [windows]
- cmd: openssl req -new -x509 -key ca-key.pem -out ca-cert.pem -days {{.VALIDITY_DAYS}} -subj '/C=CH/ST=BE/L=Bern/O=Demo AG/OU=IT Department/CN=Demo CA'
platforms: [linux, darwin]
The following two tasks create the public and private keys for the server and client, respectively, and then generate and sign their X.509 certificates with the CA. This demo uses the Ed25519 elliptic curve cryptography algorithm, another supported algorithm is RSA.
generate-server-cert:
cmds:
- echo "Creating server certificate with {{.CRYPTO_ALGORITHM}}..."
- openssl genpkey -algorithm {{.CRYPTO_ALGORITHM}} -out server-key.pem
- cmd: openssl req -new -key server-key.pem -out server.csr -subj "/C=CH/ST=BE/L=Bern/O=Demo AG/OU=Server/CN=localhost"
platforms: [windows]
- cmd: openssl req -new -key server-key.pem -out server.csr -subj '/C=CH/ST=BE/L=Bern/O=Demo AG/OU=Server/CN=localhost'
platforms: [linux, darwin]
- |
cat > server.conf << EOF
[req]
distinguished_name = req_distinguished_name
req_extensions = v3_req
prompt = no
[req_distinguished_name]
C = CH
ST = BE
L = Bern
O = Demo AG
OU = Server
CN = localhost
[v3_req]
keyUsage = keyEncipherment, dataEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
[alt_names]
DNS.1 = localhost
DNS.2 = *.localhost
IP.1 = 127.0.0.1
IP.2 = ::1
EOF
- openssl x509 -req -in server.csr -CA ca-cert.pem -CAkey ca-key.pem -CAcreateserial -out server-cert.pem -days {{.VALIDITY_DAYS}} -extensions v3_req -extfile server.conf
generate-client-cert:
cmds:
- echo "Creating client certificate with {{.CRYPTO_ALGORITHM}}..."
- openssl genpkey -algorithm {{.CRYPTO_ALGORITHM}} -out client-key.pem
- cmd: openssl req -new -key client-key.pem -out client.csr -subj "/C=CH/ST=BE/L=Bern/O=Demo AG/OU=Client/CN=demo-client"
platforms: [windows]
- cmd: openssl req -new -key client-key.pem -out client.csr -subj '/C=CH/ST=BE/L=Bern/O=Demo AG/OU=Client/CN=demo-client'
platforms: [linux, darwin]
- openssl x509 -req -in client.csr -CA ca-cert.pem -CAkey ca-key.pem -CAcreateserial -out client-cert.pem -days {{.VALIDITY_DAYS}}
After generating the certificates, they are copied to the appropriate directories for the server and client applications:
- server-cert.pem and server-key.pem: Server's certificate and private key
- client-cert.pem and client-key.pem: Client's certificate and private key
- ca-cert.pem: CA certificate (copied to both server and client directories)
It is crucial that each client has its own unique certificate. In this example, we have only created a single client key pair and certificate.
mTLS with Go HTTP Server ¶
Go's standard library provides support for mTLS through the crypto/tls
and crypto/x509
packages.
The server first loads the CA certificate and the server certificate and key. The CA certificate is used to verify client certificates, while the server certificate is presented to clients during the TLS handshake.
caCert, err := os.ReadFile("ca-cert.pem")
if err != nil {
log.Fatal("Error reading CA certificate:", err)
}
caCertPool := x509.NewCertPool()
if !caCertPool.AppendCertsFromPEM(caCert) {
log.Fatal("Failed to parse CA certificate")
}
serverCert, err := tls.LoadX509KeyPair("server-cert.pem", "server-key.pem")
if err != nil {
log.Fatal("Error loading server certificate:", err)
}
With the certificates loaded, we can configure the TLS settings for the server. The tls.Config
struct allows us to specify various parameters, including the server's certificate, the CA pool for verifying client certificates, and the client authentication mode.
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{serverCert},
ClientCAs: caCertPool,
ClientAuth: tls.RequireAndVerifyClientCert, /* tls.VerifyClientCertIfGiven, */
MinVersion: tls.VersionTLS13,
}
The ClientAuth
field in the TLS configuration determines how the server handles client certificate authentication. The available options are:
- tls.NoClientCert: The server will not request a client certificate (default TLS behavior).
- tls.RequestClientCert: The server will request a client certificate, but the connection will not be terminated if the client doesn't provide one.
- tls.RequireAnyClientCert: The server will require a client certificate but won't verify it against the CA.
- tls.VerifyClientCertIfGiven: The server will verify the client certificate against the CA if provided.
- tls.RequireAndVerifyClientCert: The server will require a client certificate and verify it against the CA.
With the TLS configuration in place, we can create an HTTP server that uses this configuration. The server will listen on port 8443 and handle requests with a simple multiplexer.
mux := http.NewServeMux()
server := &http.Server{
Addr: ":8443",
TLSConfig: tlsConfig,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
Handler: mux,
}
mux.HandleFunc("GET /api/public/health", healthHandler)
mux.HandleFunc("GET /api/secure/data", secureDataHandler)
mux.HandleFunc("POST /api/secure/update", updateDataHandler)
Certificate Authentication ¶
In our Go mTLS server, we implement authentication by examining the client certificate provided during the TLS handshake. This section explains how to create secure endpoints with certificate-based authentication. Note that when this code runs, the client certificate has already been verified against the CA.
func secureDataHandler(w http.ResponseWriter, r *http.Request) {
if len(r.TLS.PeerCertificates) == 0 {
http.Error(w, "No client certificate", http.StatusUnauthorized)
return
}
clientCert := r.TLS.PeerCertificates[0]
cn := getCN(clientCert.Subject.String())
if !strings.EqualFold(cn, "demo-client") {
http.Error(w, "Certificate not authorized", http.StatusUnauthorized)
return
}
userData := UserData{
UserID: "12345",
Balance: 1500.75,
LastLogin: "2025-08-12T10:30:00Z",
}
response := SecureDataResponse{
Message: "Access granted to secure endpoint",
ClientCertificate: cn,
Authenticated: true,
Timestamp: time.Now().UnixMilli(),
Data: userData,
}
w.Header().Set("Content-Type", "application/json")
err := json.NewEncoder(w).Encode(response)
if err != nil {
http.Error(w, "Error encoding response", http.StatusInternalServerError)
}
}
To access the client certificate, the program calls r.TLS.PeerCertificates[0]
within the HTTP handler. The r.TLS
field contains information about the TLS connection, including the client certificates presented during the handshake. The first certificate in the PeerCertificates
slice is the leaf certificate, which is used to verify the connection.
We can then extract relevant information from the certificate, such as the Common Name (CN), to identify the client. In this example, we check if the CN matches "demo-client" to authorize access to secure endpoints.
The example demonstrates three endpoints:
/api/public/health
: Public endpoint accessible without authentication/api/secure/data
: Secure endpoint that requires a valid client certificate with CN "demo-client"/api/secure/update
: Secure POST endpoint with the same authentication requirements
Based on the ClientAuth
setting, the behavior of the application changes:
tls.RequireAndVerifyClientCert
With this setting, the server requires all clients to present a valid certificate. This means that even for accessing the public endpoint /api/public/health
, a valid certificate is required. However, compared to the secure endpoints, the configuration allows any client with a valid certificate to access the public endpoint. The secure endpoints can only be accessed by clients with a valid certificate that contains the CN "demo-client".
tls.VerifyClientCertIfGiven
With this setting, any client can connect to our server, regardless of whether they present a certificate. This means all clients can access the public endpoint. However, only clients with a valid certificate containing the CN "demo-client" can access the secure endpoints.
mTLS Configuration with Go HTTP Client ¶
Go's HTTP client supports mTLS by allowing you to set a custom TLSClientConfig
that contains the necessary client certificate and CA certificates. Here's how to configure the client for mTLS. The code for loading the CA certificate, client certificate, and key is similar to the server setup. Note that the CA is the one that the server certificate is signed with, not the client CA. In this example, we use the same CA for both client and server.
caCert, err := os.ReadFile("ca-cert.pem")
if err != nil {
log.Fatal("Error reading CA certificate:", err)
}
caCertPool := x509.NewCertPool()
if !caCertPool.AppendCertsFromPEM(caCert) {
log.Fatal("Failed to parse CA certificate")
}
clientCert, err := tls.LoadX509KeyPair("client-cert.pem", "client-key.pem")
if err != nil {
log.Fatal("Error loading client certificate:", err)
}
Next, the tls.Config
is created with the client certificate and the CA pool. This configuration is then used in the HTTP client's transport.
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{clientCert},
RootCAs: caCertPool,
MinVersion: tls.VersionTLS13,
}
The HTTP transport is configured with the TLS settings and additional parameters to optimize connection handling and timeouts. You can adjust these settings based on your application's requirements. The important setting here is TLSClientConfig
, which ensures that the client uses the specified TLS configuration for all requests.
transport := &http.Transport{
TLSClientConfig: tlsConfig,
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
IdleConnTimeout: 90 * time.Second,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
}
Now, we can create the HTTP client with the configured transport and a timeout for requests. The client will use mTLS for all outgoing requests.
client := &http.Client{
Transport: transport,
Timeout: 30 * time.Second,
}
Configuring the CA certificate is optional when the root certificate of the CA is already present in the operating system's trust store. For instance, Let's Encrypt is a widely trusted CA, and its certificates are included in most operating systems. In this example, however, we are using a self-signed CA for demonstration, which is not trusted by default.
After creating the HTTP client with the appropriate TLS configuration, you can use it to make requests to the server. The client automatically handles the mTLS handshake.
func testPublicEndpoint(client *http.Client) {
fmt.Println("1. Testing public endpoint (no authentication required):")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", "https://localhost:8443/api/public/health", nil)
if err != nil {
fmt.Printf(" Error creating request: %v\n", err)
return
}
resp, err := client.Do(req)
if err != nil {
fmt.Printf(" Error: %v\n", err)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf(" Error reading response: %v\n", err)
return
}
fmt.Printf(" Status: %d\n", resp.StatusCode)
fmt.Printf(" Response: %s\n", string(body))
}
Conclusion ¶
Implementing mTLS with Go provides a robust foundation for zero-trust architectures and high-security applications. Go's standard library makes it straightforward to implement mTLS without external dependencies.
While the setup requires careful certificate management and additional operational complexity, the security benefits—particularly the mutual authentication and protection against various attack vectors—make it worthwhile for scenarios requiring strong client authentication.
The setup shown in this post is feasible for small to medium deployments. For handling thousands of clients, a proper Public Key Infrastructure (PKI) is necessary to manage certificates and keys efficiently. You can use tools like HashiCorp Vault, cloud-based certificate managers, or other PKI solutions to automate the certificate issuance and management process.