Look, TLS configuration is one of those things that separates the pros from the amateurs. It’s not enough to just slap tls.Config{} on your http.Server and call it a day. That’s like installing a vault door but leaving the key under the mat. The Go standard library gives you the tools to build a fortress, but it’s up to you to not build it with glaring weaknesses. Let’s get into the weeds.

The Absolute Minimum Viable Config

First, let’s get something running that won’t make a security researcher weep. The biggest rookie mistake is letting the Go standard library choose its own default cipher suites. Its historical defaults have been… let’s say generous to backward compatibility. We’re going to be explicit.

package main

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

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Hello, secure world!"))
    })

    // This is our non-negotiable baseline.
    tlsConfig := &tls.Config{
        MinVersion: tls.VersionTLS12,
        CurvePreferences: []tls.CurveID{
            tls.X25519, // The cool kid's curve
            tls.CurveP256,
        },
        CipherSuites: []uint16{
            // These are the modern, robust suites. Order matters!
            // The server will pick the first one the client also supports.
            tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
            tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
            tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
            tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
            tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
            tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
        },
    }

    server := &http.Server{
        Addr:      ":8443",
        Handler:   mux,
        TLSConfig: tlsConfig,
    }
    server.ListenAndServeTLS("cert.pem", "key.pem")
}

Why this list? We’re prioritizing AEAD ciphers (like AES-GCM and ChaCha20-Poly1305) which provide both confidentiality and authentication. We’re also insisting on ECDHE (Elliptic Curve Diffie-Hellman Ephemeral) for forward secrecy. This means even if someone steals your server’s private key months from now, they can’t decrypt today’s captured traffic. Non-negotiable.

Banishing the Ghosts of TLS Past

Setting MinVersion: tls.VersionTLS12 is the single most important line in that config. Go, thankfully, disabled TLS 1.0 and 1.1 by default in Go 1.18, but being explicit protects you if your code ever runs on an older version. You should actively be looking to move to TLS 1.3. Why isn’t it the min version here? Because you might still need to support some legacy client. But that should be a temporary state, not a permanent one.

// Aim for this. TLS 1.3 is simpler and more secure.
tlsConfig := &tls.Config{
    MinVersion: tls.VersionTLS13,
}

Here’s the beautiful part about TLS 1.3: the cipher suite negotiation is vastly simplified. Most of the choices we agonized over in the first example are irrelevant. TLS 1.3 cipher suites are all AEAD and inherently provide forward secrecy. The list in a TLS 1.3 config is just for saying “I prefer this one over that one.”

The Certificate Trap

You’ve got your ciphers locked down. Great. Now, about that cert.pem and key.pem… where did they come from? If you typed openssl req -new -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem and said “yes” to everything, you’ve created a self-signed certificate. Your browser will throw a terrifying error to users. This is fine for local development but a disaster for production.

For production, you need a certificate from a real Certificate Authority (CA). Use Let’s Encrypt. It’s free, automated, and exactly what everyone else is using. The best way to use it in Go is via the golang.org/x/crypto/acme/autocert package. It manages your certificates, renews them automatically, and even caches them to disk so you don’t hit rate limits.

package main

import (
    "crypto/tls"
    "golang.org/x/crypto/acme/autocert"
    "log"
    "net/http"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Hello from Let's Encrypt!"))
    })

    certManager := autocert.Manager{
        Prompt:     autocert.AcceptTOS,
        // Replace with your actual domain
        HostPolicy: autocert.HostWhitelist("yourdomain.com", "www.yourdomain.com"),
        // Directory to cache certificates to avoid rate limits
        Cache:      autocert.DirCache("/var/www/.cache"),
    }

    server := &http.Server{
        Addr: ":443",
        Handler: mux,
        TLSConfig: &tls.Config{
            GetCertificate: certManager.GetCertificate, // This is the magic
            MinVersion:     tls.VersionTLS13, // Go for the gold
        },
    }

    // You MUST also listen on :80 to satisfy the HTTP challenge
    go func() {
        log.Fatal(http.ListenAndServe(":80", certManager.HTTPHandler(nil)))
    }()

    log.Fatal(server.ListenAndServeTLS("", "")) // Certs are handled by Manager, so empty strings
}

The Curious Case of InsecureSkipVerify

You will see this in tutorials and Stack Overflow answers for testing. Do not use this in production. Ever. It does exactly what it says: it skips verifying the certificate chain of the server you’re connecting to. It completely neutures the “T” in TLS. If you’re writing a client and need to connect to a server with a self-signed cert for testing, don’t use this. Instead, load the self-signed CA cert into a x509.CertPool and use it in your tls.Config.

// The RIGHT way to trust a custom CA (e.g., for internal testing)
certPool := x509.NewCertPool()
pem, err := os.ReadFile("company-ca.pem")
if err != nil {
    log.Fatal(err)
}
if ok := certPool.AppendCertsFromPEM(pem); !ok {
    log.Fatal("failed to append CA cert")
}

client := &http.Client{
    Transport: &http.Transport{
        TLSClientConfig: &tls.Config{
            RootCAs: certPool, // Use your custom pool, not the default one
        },
    },
}

This way, you’re still verifying the certificate, you’re just trusting a different authority. This is secure. InsecureSkipVerify is not. The name couldn’t be clearer. Listen to it.