Skip to content

Serve HTTPS with hardened defaults

By the end of this you'll have a Go HTTPS server using the package's hardened TLS config, a certificate you made yourself, and a client that trusts it. About ten minutes.

You'll need Go 1.26 or newer and openssl on your path. Everything happens in one scratch directory and nothing is installed system-wide.

The certificate you make here is self-signed and only good for localhost. Browsers and curl will refuse it until you tell them to trust it, which is the last step.

Set up the project

mkdir https-demo && cd https-demo
go mod init httpsdemo
go get gitlab.com/phpboyscout/go/tls

The module pulls in one non-standard-library dependency, github.com/cockroachdb/errors. That is the whole footprint.

Make a certificate for localhost

openssl generates a self-signed certificate and its key. The subjectAltName is the part that matters — modern clients ignore the common name and check the SAN, so without it the certificate is rejected even when everything else is right.

mkdir certs
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
  -keyout certs/server-key.pem -out certs/server.pem -days 365 \
  -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"

Check it landed:

openssl x509 -in certs/server.pem -noout -subject -ext subjectAltName
subject=CN = localhost
X509v3 Subject Alternative Name:
    DNS:localhost, IP Address:127.0.0.1

-nodes leaves the key unencrypted, which is what a server process needs and what you want for a throwaway. Keep it out of version control.

Write the server

A Pair is the typed settings block: the two paths, plus a flag saying TLS is meant to be on. ServerConfig loads the certificate into the hardened config and hands back a *crypto/tls.Config ready for http.Server.

// server/main.go
package main

import (
    "fmt"
    "log"
    "net/http"

    "gitlab.com/phpboyscout/go/tls"
)

func main() {
    pair := tls.Pair{
        Enabled: true,
        Cert:    "certs/server.pem",
        Key:     "certs/server-key.pem",
    }

    cfg, err := pair.ServerConfig("h2", "http/1.1")
    if err != nil {
        log.Fatal(err)
    }

    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "served over %s\n", r.Proto)
    })

    srv := &http.Server{Addr: "127.0.0.1:8443", TLSConfig: cfg, Handler: mux}

    log.Println("listening on https://localhost:8443")
    log.Fatal(srv.ListenAndServeTLS("", ""))
}

Two things in there are easy to get wrong:

  • The arguments to ServerConfig are ALPN protocol names. "h2", "http/1.1" advertises HTTP/2 with an HTTP/1.1 fallback. Pass nothing and the server advertises no ALPN at all, which for net/http means no HTTP/2.
  • ListenAndServeTLS("", "") takes empty strings because the certificate is already in TLSConfig. Passing paths here as well would load it a second time.

Run it from the project root, so the relative certificate paths resolve:

go run ./server
2026/08/02 18:35:46 listening on https://localhost:8443

If the paths are wrong you get an error before anything listens — loading TLS certificate: open certs/server.pem: no such file or directory. That is the intended behaviour: a bad certificate stops the process at startup rather than breaking the first request.

Watch a client reject the certificate

Leave the server running and, in another terminal, ask curl for the page:

curl https://localhost:8443/
curl: (60) SSL certificate problem: self-signed certificate

Nothing is broken. The certificate is signed by nobody curl has heard of, and verification is on. This is what a client is supposed to do, and it is the same failure a Go client using tls.ClientConfig() with no arguments would hit.

Tell the client to trust it

Point curl at the certificate as a trust anchor:

curl -v --cacert certs/server.pem https://localhost:8443/
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256 / X25519 / id-ecPublicKey
* ALPN: server accepted h2
served over HTTP/2.0

Three things worth reading in that output. The connection negotiated TLS 1.3, even though the package pins a TLS 1.2 floor — the floor is a minimum, not a target. The cipher, TLS_AES_128_GCM_SHA256, is a TLS 1.3 suite and deliberately not one of the six the package lists; that list governs TLS 1.2 connections. And ALPN picked h2, so the request ran over HTTP/2.

The Go equivalent of --cacert is ClientConfig:

cfg, err := tls.ClientConfig("certs/server.pem")
if err != nil {
    log.Fatal(err)
}

client := &http.Client{Transport: &http.Transport{TLSClientConfig: cfg}}

Call it with no arguments and the client trusts the system root store instead, which is what you want against a publicly-issued certificate.

Be careful with that argument in a real client: passing CA files replaces system trust rather than adding to it, so a client built this way can no longer verify any public certificate. Private-CA trust replaces the system roots covers what to do when you need both.

Decide between HTTPS and plaintext at startup

Pair.Valid reports whether TLS is enabled and both paths are set. It is the predicate to branch on when the same binary sometimes runs without certificates:

switch {
case pair.Valid():
    cfg, err := pair.ServerConfig("h2", "http/1.1")
    // ... serve HTTPS
case pair.Enabled:
    log.Fatal("TLS enabled but cert/key path is missing")
default:
    // ... serve plain HTTP
}

Do that check yourself, because nothing else will: ServerConfig never looks at Enabled and will happily build a TLS config for a pair that says enabled: false.

Where to go next