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¶
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:
-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
ServerConfigare 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 fornet/httpmeans no HTTP/2. ListenAndServeTLS("", "")takes empty strings because the certificate is already inTLSConfig. Passing paths here as well would load it a second time.
Run it from the project root, so the relative certificate paths resolve:
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:
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:
* 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¶
- Require a client certificate — the same server, with mutual TLS.
- Harden a server & client — ALPN, private CAs, and one certificate across several listeners.
- Configuration keys — every field of
Pairand what a wrong value does. - Security threat model — why the defaults you just used are what they are.