TLS: serving and verifying HTTPS

How to serve HTTPS in Go, what changes when a proxy terminates TLS, and how outbound calls verify certificates.

Syntaxfunc (srv *http.Server) ListenAndServeTLS(certFile, keyFile string) error

TLS is the layer that encrypts bytes as they travel between a client and a server. HTTPS is ordinary HTTP carried inside TLS. Without it, anyone on the network path can read the request and change the reply.

A server needs two files. The certificate says that a public key belongs to a name such as api.example.com, and it is signed by a certificate authority that clients already trust. The private key is the matching secret. It never leaves the server.

Serving HTTPS

srv.ListenAndServeTLS("cert.pem", "key.pem") reads both files and serves. Set TLSConfig.MinVersion to tls.VersionTLS12 so an old client cannot negotiate a weak version. The package function http.ListenAndServeTLS(addr, certFile, keyFile, handler) is a shorthand: it builds an http.Server with that address and handler and calls the method on it. They are the same thing, but only the long form lets you set TLSConfig and timeouts.

Terminating at a proxy

Most services never hold a certificate. A load balancer or a Kubernetes ingress decrypts the traffic and forwards plain HTTP to your process over a private network. Your server then listens on HTTP and learns the original scheme from the X-Forwarded-Proto header. Trust that header only when the proxy overwrites it on every request, because a client can send it too.

Outbound calls

An http.Client verifies the certificate of every server it calls, using the machine's root list. For a private authority, load its certificate into an x509.CertPool and set it as RootCAs. InsecureSkipVerify switches verification off entirely, which leaves the encryption worthless against anyone who can intercept the connection.

Example

Example · go
package main

import (
	"fmt"
	"io"
	"log"
	"net/http"
	"net/http/httptest"
)

func main() {
	// NewTLSServer starts a real HTTPS server holding a certificate it made
	// for itself. No public authority signed that certificate.
	srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "served over %s\n", r.Proto)
	}))
	defer srv.Close()

	// srv.Client() is an *http.Client told to trust that one certificate.
	// A plain http.Get would fail, because verification finds no known signer.
	resp, err := srv.Client().Get(srv.URL)
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Print(string(body))

}

// Output:
// served over HTTP/1.1

When to use it

  • Serve an internal admin tool over HTTPS with no proxy in front of it.
  • Call a payment API and fail the request if its certificate does not verify.
  • Talk to a service whose certificate was signed by your company's own authority.
  • Redirect plain HTTP to HTTPS behind an ingress that terminates TLS.

More examples

A server that holds its own certificate

The long form of serving HTTPS. Reach for it whenever the Go process itself terminates TLS, because TLSConfig and the timeout fields are only reachable this way. Check it with: curl -k https://localhost:8443/healthz (the -k flag skips verification, which a self-signed certificate needs).

Example · go
package main

import (
	"crypto/tls"
	"log"
	"net/http"
	"time"
)

func main() {
	mux := http.NewServeMux()
	// The method in the pattern needs Go 1.22 or newer.
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("ok"))
	})

	srv := &http.Server{
		Addr:              ":8443",
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
		TLSConfig:         &tls.Config{MinVersion: tls.VersionTLS12},
	}

	// Both files are PEM encoded text. The key file holds the secret, so its
	// permissions should let only this process read it.
	log.Fatal(srv.ListenAndServeTLS("cert.pem", "key.pem"))
}

Calling a service signed by a private authority

Companies often sign internal certificates themselves, and the machine's root list has never heard of that authority. This is the correct fix. The wrong fix is InsecureSkipVerify, which accepts any certificate from anyone.

Example · go
package main

import (
	"crypto/tls"
	"crypto/x509"
	"log"
	"net/http"
	"os"
	"time"
)

func main() {
	pem, err := os.ReadFile("internal-ca.pem")
	if err != nil {
		log.Fatal(err)
	}

	// A pool is a set of authorities to trust. Starting from an empty pool
	// means this client trusts the internal authority and nothing else.
	pool := x509.NewCertPool()
	if !pool.AppendCertsFromPEM(pem) {
		log.Fatal("no certificates found in internal-ca.pem")
	}

	client := &http.Client{
		Timeout: 10 * time.Second,
		Transport: &http.Transport{
			TLSClientConfig: &tls.Config{
				MinVersion: tls.VersionTLS12,
				RootCAs:    pool,
			},
		},
	}

	resp, err := client.Get("https://billing.internal/invoices")
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()
	log.Println(resp.Status)
}

Behind a proxy that already terminated TLS

The usual production shape: no certificate in the Go process at all. Use this when something in front of you already does TLS, and pair it with the Strict-Transport-Security header so browsers stop trying plain HTTP in the first place.

Example · go
package main

import (
	"log"
	"net/http"
)

// requireHTTPS suits a server behind a load balancer that speaks TLS to the
// outside and plain HTTP to this process. The balancer must overwrite
// X-Forwarded-Proto on every request, or a client can claim https itself.
func requireHTTPS(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get("X-Forwarded-Proto") == "http" {
			target := "https://" + r.Host + r.URL.RequestURI()
			http.Redirect(w, r, target, http.StatusMovedPermanently)
			return
		}
		next.ServeHTTP(w, r)
	})
}

func main() {
	h := requireHTTPS(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("ok"))
	}))
	log.Fatal(http.ListenAndServe(":8080", h))
}

Discussion

  • Be the first to comment on this lesson.