Home | Send Feedback | Share on Bluesky |

mTLS with Go

Published: 8. September 2025  •  go

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:

Downsides

While mTLS provides strong security benefits, it also comes with several challenges:

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]

Taskfile.yml

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}}

Taskfile.yml

After generating the certificates, they are copied to the appropriate directories for the server and client applications:

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)
  }

main.go

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,
  }

main.go

The ClientAuth field in the TLS configuration determines how the server handles client certificate authentication. The available options are:


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)

main.go

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)
  }
}

main.go

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:


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)
  }

main.go

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,
  }

main.go

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,
  }

main.go

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,
  }

main.go

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))
}

main.go

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.