Skip to content

Harden a server & client

Recipes for the common production shapes: advertising ALPN, trusting a private CA on both ends, requiring client certificates, and letting one certificate serve several transports with targeted overrides.

Each one starts from a config the package built and, where the package stops, says what to set by hand. Anything the package deliberately will not do is listed in What this package does not do.

ServerConfig takes a variadic list of ALPN protocol identifiers. Pass "h2" first to prefer HTTP/2, or "h2" alone for a raw gRPC TLS listener:

// HTTP server: prefer HTTP/2, fall back to HTTP/1.1
httpCfg, err := pair.ServerConfig("h2", "http/1.1")

// gRPC TLS listener: HTTP/2 only
grpcCfg, err := pair.ServerConfig("h2")

With no arguments the returned config leaves NextProtos unset and the standard library negotiates as normal.

Trust a private CA on both ends

When a server presents a certificate signed by an internal or self-signed CA, the client must be told to trust it. CertPool builds an *x509.CertPool from one or more PEM files, and ClientConfig wires that pool into the hardened config for you:

// Client trusts the private CA — and nothing else. See the warning below.
clientCfg, err := tls.ClientConfig("/etc/certs/ca.pem")
if err != nil {
    log.Fatal(err)
}

ClientConfig returns an error if a CA file is missing or contains no certificates, so a broken trust anchor is caught at construction. Pass the same CA file the servers were issued from to share one trust anchor across your gRPC, HTTP and gateway clients.

To build a pool directly — for example to set RootCAs on a config you own:

pool, err := tls.CertPool("/etc/certs/ca.pem", "/etc/certs/old-ca.pem")

Trust a private CA and the public internet

Passing CA files to ClientConfig replaces the system root store rather than adding to it: CertPool starts from an empty x509.NewCertPool(). A client built that way can reach your internal services and nothing else — every public certificate now fails with x509: certificate signed by unknown authority.

The package has no option to combine the two, because there is no partially-trusting pool to hand you. Build it yourself:

pool, err := x509.SystemCertPool()
if err != nil {
    log.Fatal(err)
}

pem, err := os.ReadFile("/etc/certs/ca.pem")
if err != nil {
    log.Fatal(err)
}

if !pool.AppendCertsFromPEM(pem) {
    log.Fatal("no certificates found in /etc/certs/ca.pem")
}

cfg := tls.DefaultConfig()
cfg.RootCAs = pool

SystemCertPool returns a copy, so appending to it affects nothing else in the process. Check the boolean from AppendCertsFromPEM — it is the only signal that the file held anything, and CertPool exists partly to stop that check being forgotten.

Present a client certificate (mTLS from the client side)

ClientConfig configures trust and nothing else; it has no parameter for the client's own certificate. Load it from a Pair and assign it:

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

cert, err := tls.Pair{
    Enabled: true,
    Cert:    "/etc/certs/client.pem",
    Key:     "/etc/certs/client-key.pem",
}.Certificate()
if err != nil {
    log.Fatal(err)
}

cfg.Certificates = []cryptotls.Certificate{cert}

ClientCAs and ClientAuth on that pair would be ignored — they are server-side fields, read only when building a server config.

Worked end to end, including the certificates, in Require a client certificate.

Require client certificates (mTLS)

The pair's optional client-CA fields drive server-side mutual TLS. ClientCAs lists the PEM CA files client certificates must chain to; ClientAuth picks the enforcement mode:

ClientAuth crypto/tls mode Behaviour
request RequestClientCert Ask for a certificate; never require or verify it
verify-if-given VerifyClientCertIfGiven Optional certificate, but verify any that is presented (mixed-auth listeners)
require-verify RequireAndVerifyClientCert Require a certificate chaining to ClientCAs
(empty) No client-cert auth — unless ClientCAs is set, which implies require-verify (fail closed)

Any other value is a construction error, and the verifying modes error without ClientCAs to verify against — a typo can never silently disable mTLS.

pair := tls.Pair{
    Enabled:    true,
    Cert:       "/etc/certs/server.pem",
    Key:        "/etc/certs/server-key.pem",
    ClientCAs:  []string{"/etc/certs/client-ca.pem"},
    ClientAuth: tls.ClientAuthRequireVerify,
}

cfg, err := pair.ServerConfig("h2") // ClientCAs pool + RequireAndVerifyClientCert set

Merge into a config you own

When you hand-build a server config (custom VerifyPeerCertificate, session tickets, GetCertificate, …), Pair.ApplyTo merges the pair into it instead of replacing it:

cfg := &cryptotls.Config{MinVersion: cryptotls.VersionTLS13 /* , custom hooks… */}
if err := pair.ApplyTo(cfg, "h2"); err != nil {
    log.Fatal(err)
}

ApplyTo appends the pair's certificate, appends each ALPN protocol not already advertised, and applies the pair's client-CA policy only when the config does not already carry one — everything the caller set survives. Precedence: if the config already has a non-nil ClientCAs pool or a ClientAuth other than NoClientCert, the caller's client-certificate policy wins and the pair's ClientCAs/ClientAuth fields are ignored (an invalid ClientAuth string still errors). ServerConfig is exactly DefaultConfig + ApplyTo.

Two things ApplyTo does not do, both of which bite:

  • It does not harden the config. MinVersion, CipherSuites and CurvePreferences are left exactly as you set them. Start from tls.DefaultConfig() if you want the posture as well as the certificate.
  • It does not enforce the pair's mTLS policy onto a config that has half of one. A config carrying a ClientCAs pool but with ClientAuth still at NoClientCert counts as "already has a policy", so a pair asking for require-verify is silently ignored and the listener requires no client certificate. Set cfg.ClientAuth yourself in that case, or use ServerConfig.

Calling ApplyTo twice with the same pair appends the certificate twice — it is not idempotent.

One certificate, several transports

A service often terminates TLS on more than one listener (HTTP, gRPC, a gateway). You usually want a single shared certificate, with the option to override it per transport. ResolvePair performs that merge on already-materialised typed values — no config-lookup interface required:

shared := tls.Pair{Enabled: true, Cert: "/etc/certs/shared.pem", Key: "/etc/certs/shared-key.pem"}

// The gRPC listener overrides only the certificate; everything else
// falls back to the shared pair.
grpcSpecific := tls.Pair{Cert: "/etc/certs/grpc.pem"}

grpcPair := tls.ResolvePair(shared, grpcSpecific, tls.PairOverrides{
    Cert: true, // only Cert was explicitly set for gRPC
})
// grpcPair.Enabled == true (shared), grpcPair.Cert == "/etc/certs/grpc.pem",
// grpcPair.Key == "/etc/certs/shared-key.pem" (shared)

The PairOverrides mask records which fields the per-transport section actually set, so an unset field never clobbers the shared value with a zero. You decide how a field counts as "set" — a config key being present, a non-empty flag, an environment variable — and express it as the boolean mask.

Decide TLS vs. plaintext at boot

Pair.Valid is the single predicate for "is this pair usable for TLS":

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
}

Branch on it yourself. ServerConfig does not consult Enabled, so a pair with enabled: false and valid paths still produces a working TLS config.

Require TLS 1.3, or change the suites

None of the posture is configurable through Pair — there is no min_version key and DefaultConfig takes no arguments. Set the field on the config you get back:

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

cfg.MinVersion = cryptotls.VersionTLS13 // refuse anything below 1.3

At TLS 1.3 the CipherSuites list stops mattering — the 1.3 suites are fixed by the protocol — so a 1.3-only listener needs nothing else changed.

To restore Go's own key-exchange choice, including the post-quantum X25519MLKEM768 hybrid that an explicit curve list opts out of:

cfg.CurvePreferences = nil

Both edits survive: nothing in the package re-reads or resets the config after it is returned.

Rotate a certificate without restarting

ServerConfig reads the certificate once. A renewed file written over the same path is not picked up by a running listener.

crypto/tls calls GetCertificate on every handshake, so wiring that to something that re-reads on a timer gives you rotation. Build the config yourself and let ApplyTo fill in the rest:

var current atomic.Pointer[cryptotls.Certificate]

reload := func() error {
    cert, err := pair.Certificate() // re-reads both files
    if err != nil {
        return err
    }

    current.Store(&cert)

    return nil
}

cfg := tls.DefaultConfig()
cfg.GetCertificate = func(*cryptotls.ClientHelloInfo) (*cryptotls.Certificate, error) {
    return current.Load(), nil
}

if err := pair.ApplyTo(cfg, "h2", "http/1.1"); err != nil { // ALPN + client-CA policy
    log.Fatal(err)
}

cfg.Certificates = nil // see below — do not leave a stale static copy here

Call reload once before serving and again on whatever signal suits — a ticker, a filesystem watch, SIGHUP. Keep serving the old certificate if a reload fails; a stale certificate beats a listener with none.

Clear cfg.Certificates afterwards. ApplyTo appends the certificate it loaded, and crypto/tls only prefers GetCertificate over a populated Certificates list when the client sent SNI. A client connecting by IP, or any client that omits SNI, gets the certificate loaded at startup — which is exactly the one rotation was supposed to replace. Setting the field to nil after the merge leaves GetCertificate as the only source. ApplyTo is still worth calling: it validates the paths at startup and applies the ALPN and client-CA policy.