HTTPS in Go: Going Beyond Plain HTTP

Go's standard library makes serving HTTPS with TLS almost as simple as plain HTTP. The cryptographic handshaking, certificate exchange, and encryption are handled for you — the difference between an HTTP and HTTPS server in Go is really just a few lines of configuration.

Certificates: The Foundation of Trust

TLS relies on public-key cryptography and X.509 certificates (RFC 5280) to establish trust. When a client connects, the server sends its certificate containing a public key, its identity, and a signature from a trusted authority. Browsers ship with a list of these trusted certificate authorities (CAs). This prevents man-in-the-middle attacks: a snooper on your network can't forge a trusted CA's signature, so they can't impersonate bigbank.com to steal your password.

Generating Self-Signed Certificates

For local testing, self-signed certificates are invaluable. They're signed by their own private key rather than a known CA. Go's crypto/x509 package gives you everything needed to generate one programmatically.

First, generate an ECDSA key pair using the P-256 curve — one of the curves allowed in TLS 1.3:

privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
  log.Fatalf("Failed to generate private key: %v", err)
}

Next, build a certificate template. It needs a unique serial number — a random 128-bit number suffices locally. The template below creates a certificate valid for 3 hours for the localhost domain:

serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
  log.Fatalf("Failed to generate serial number: %v", err)
}

template := x509.Certificate{
  SerialNumber: serialNumber,
  Subject: pkix.Name{
    Organization: []string{"My Corp"},
  },
  DNSNames:  []string{"localhost"},
  NotBefore: time.Now(),
  NotAfter:  time.Now().Add(3 * time.Hour),

  KeyUsage:              x509.KeyUsageDigitalSignature,
  ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
  BasicConstraintsValid: true,
}

Create and sign the certificate. Passing &template in both the template and parent parameters of CreateCertificate makes it self-signed:

derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
  log.Fatalf("Failed to create certificate: %v", err)
}

Finally, serialize both to PEM files:

pemCert := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
if pemCert == nil {
  log.Fatal("Failed to encode certificate to PEM")
}
if err := os.WriteFile("cert.pem", pemCert, 0644); err != nil {
  log.Fatal(err)
}
log.Print("wrote cert.pem\n")
privBytes, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
  log.Fatalf("Unable to marshal private key: %v", err)
}
pemKey := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes})
if pemKey == nil {
  log.Fatal("Failed to encode key to PEM")
}
if err := os.WriteFile("key.pem", pemKey, 0600); err != nil {
  log.Fatal(err)
}
log.Print("wrote key.pem\n")

The certificate PEM looks like familiar base64-encoded key material:

-----BEGIN CERTIFICATE-----
MIIBbjCCARSgAwIBAgIRALBCBgLhD1I/4S0fRZv6yfcwCgYIKoZIzj0EAwIwEjEQ
MA4GA1UEChMHTXkgQ29ycDAeFw0yMTAzMjcxNDI1NDlaFw0yMTAzMjcxNzI1NDla
MBIxEDAOBgNVBAoTB015IENvcnAwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASf
wNSifB2LWDeb6xUAWbwnBQ2raSQTqqpaR1C1eEiy6cgqUiiOlr4jUDDiFCly+AS9
pNNe8o63/Gab/98dwFNQo0swSTAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYI
KwYBBQUHAwEwDAYDVR0TAQH/BAIwADAUBgNVHREEDTALgglsb2NhbGhvc3QwCgYI
KoZIzj0EAwIDSAAwRQIgYlJYGIwSvA+AmsHe8P34B5+hlfWEK4+kBmydJ65XJZMC
IQCzg5aihUXh7Rm0L1K3JrG7eRuTuFSkHoAhzk4cy6FqfQ==
-----END CERTIFICATE-----

Running the HTTPS Server

With the certificate and private key on disk, serving HTTPS is straightforward. The example below forces TLS 1.3, which provides strong security out of the box:

func main() {
  addr := flag.String("addr", ":4000", "HTTPS network address")
  certFile := flag.String("certfile", "cert.pem", "certificate PEM file")
  keyFile := flag.String("keyfile", "key.pem", "key PEM file")
  flag.Parse()

  mux := http.NewServeMux()
  mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
    if req.URL.Path != "/" {
      http.NotFound(w, req)
      return
    }
    fmt.Fprintf(w, "Proudly served with Go and HTTPS!")
  })

  srv := &http.Server{
    Addr:    *addr,
    Handler: mux,
    TLSConfig: &tls.Config{
      MinVersion:               tls.VersionTLS13,
      PreferServerCipherSuites: true,
    },
  }

  log.Printf("Starting server on %s", *addr)
  err := srv.ListenAndServeTLS(*certFile, *keyFile)
  log.Fatal(err)
}

Beyond the TLS config and the ListenAndServeTLS call (which takes the cert and key file paths), the code is identical to an HTTP server. Route handlers are completely unaware of the underlying protocol.

Browsers and curl will initially reject the self-signed cert since it's not in their trusted CA store. Chrome shows a warning before you proceed; curl fails entirely:

$ curl -Lv  https://localhost:4000

*   Trying 127.0.0.1:4000...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 4000 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
*   CAfile: /etc/ssl/certs/ca-certificates.crt
  CApath: /etc/ssl/certs
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (OUT), TLS alert, unknown CA (560):
* SSL certificate problem: unable to get local issuer certificate
* Closing connection 0
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.haxx.se/docs/sslcerts.html

curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the web page mentioned above.

You can have curl trust the cert explicitly with --cacert.

A custom Go HTTPS client needs similar configuration. The key is setting RootCAs in the tls.Config to tell the client which certificate authorities to trust:

func main() {
  addr := flag.String("addr", "localhost:4000", "HTTPS server address")
  certFile := flag.String("certfile", "cert.pem", "trusted CA certificate")
  flag.Parse()

  cert, err := os.ReadFile(*certFile)
  if err != nil {
    log.Fatal(err)
  }
  certPool := x509.NewCertPool()
  if ok := certPool.AppendCertsFromPEM(cert); !ok {
    log.Fatalf("unable to parse cert from %s", *certFile)
  }

  client := &http.Client{
    Transport: &http.Transport{
      TLSClientConfig: &tls.Config{
        RootCAs: certPool,
      },
    },
  }

  r, err := client.Get("https://" + *addr)
  if err != nil {
    log.Fatal(err)
  }
  defer r.Body.Close()

  html, err := io.ReadAll(r.Body)
  if err != nil {
    log.Fatal(err)
  }
  fmt.Printf("%v\n", r.Status)
  fmt.Printf(string(html))
}

Alternative Certificate Sources

Go's standard installation includes generate_cert, which you can run from the Go root:

$ go run /usr/local/go/src/crypto/tls/generate_cert.go -help

It mirrors the programmatic approach but is configurable via flags.

For a seamless local experience, mkcert creates a local CA and registers it in your system's trusted store. Certificates it generates are fully trusted by Chrome, curl, and other tools without warnings or special flags.

For production, use Let's Encrypt with certbot or a Go library like certmagic to automate certificate issuance and renewal.

Mutual TLS (mTLS)

Standard TLS validates the server to the client. Mutual TLS flips that around as well: the client also presents a signed certificate, proving its identity. This is useful for secure service-to-service communication.

The mTLS server is nearly identical to the earlier version, but loads a client CA cert and updates the TLS config:

func main() {
  addr := flag.String("addr", ":4000", "HTTPS network address")
  certFile := flag.String("certfile", "cert.pem", "certificate PEM file")
  keyFile := flag.String("keyfile", "key.pem", "key PEM file")
  clientCertFile := flag.String("clientcert", "clientcert.pem", "certificate PEM for client authentication")
  flag.Parse()

  mux := http.NewServeMux()
  mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
    if req.URL.Path != "/" {
      http.NotFound(w, req)
      return
    }
    fmt.Fprintf(w, "Proudly served with Go and HTTPS!")
  })

  // Trusted client certificate.
  clientCert, err := os.ReadFile(*clientCertFile)
  if err != nil {
    log.Fatal(err)
  }
  clientCertPool := x509.NewCertPool()
  clientCertPool.AppendCertsFromPEM(clientCert)

  srv := &http.Server{
    Addr:    *addr,
    Handler: mux,
    TLSConfig: &tls.Config{
      MinVersion:               tls.VersionTLS13,
      PreferServerCipherSuites: true,
      ClientCAs:                clientCertPool,
      ClientAuth:               tls.RequireAndVerifyClientCert,
    },
  }

  log.Printf("Starting server on %s", *addr)
  err = srv.ListenAndServeTLS(*certFile, *keyFile)
  log.Fatal(err)
}

The corresponding client configures itself with its own certificate so the server can verify it:

func main() {
  addr := flag.String("addr", "localhost:4000", "HTTPS server address")
  certFile := flag.String("certfile", "cert.pem", "trusted CA certificate")
  clientCertFile := flag.String("clientcert", "clientcert.pem", "certificate PEM for client")
  clientKeyFile := flag.String("clientkey", "clientkey.pem", "key PEM for client")
  flag.Parse()

  // Load our client certificate and key.
  clientCert, err := tls.LoadX509KeyPair(*clientCertFile, *clientKeyFile)
  if err != nil {
    log.Fatal(err)
  }

  // Trusted server certificate.
  cert, err := os.ReadFile(*certFile)
  if err != nil {
    log.Fatal(err)
  }
  certPool := x509.NewCertPool()
  if ok := certPool.AppendCertsFromPEM(cert); !ok {
    log.Fatalf("unable to parse cert from %s", *certFile)
  }

  client := &http.Client{
    Transport: &http.Transport{
      TLSClientConfig: &tls.Config{
        RootCAs:      certPool,
        Certificates: []tls.Certificate{clientCert},
      },
    },
  }

  r, err := client.Get("https://" + *addr)
  if err != nil {
    log.Fatal(err)
  }
  defer r.Body.Close()

  html, err := io.ReadAll(r.Body)
  if err != nil {
    log.Fatal(err)
  }
  fmt.Printf("%v\n", r.Status)
  fmt.Printf(string(html))
}

Both server and client certificate templates need the appropriate extended usage. Change this line in your certificate generation script from:

ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},

to:

ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},

Generate separate cert/key pairs for each side and run the mTLS server:

# client cert

$ go run tls-self-signed-cert.go
2021/04/03 05:51:25 wrote cert.pem
2021/04/03 05:51:25 wrote key.pem
$ mv cert.pem clientcert.pem
$ mv key.pem clientkey.pem

# server cert

$ go run tls-self-signed-cert.go
2021/04/03 05:51:42 wrote cert.pem
2021/04/03 05:51:42 wrote key.pem
$ go run https-server-mtls.go
2021/04/03 05:54:51 Starting server on :4000

A regular (non-mTLS) client against the mTLS server fails with an error like "client didn't provide a certificate." The mTLS client succeeds:

$ go run https-client.go
2021/04/03 05:55:24 Get "https://localhost:4000": remote error: tls: bad certificate
exit status 1
$ go run https-client-mtls.go
200 OK
Proudly served with Go and HTTPS!

Real-world mTLS requires managing certificate renewal, revocation, and a proper Public Key Infrastructure — a significant topic on its own.