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.
Advertise ALPN protocols¶
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:
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,CipherSuitesandCurvePreferencesare left exactly as you set them. Start fromtls.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
ClientCAspool but withClientAuthstill atNoClientCertcounts as "already has a policy", so a pair asking forrequire-verifyis silently ignored and the listener requires no client certificate. Setcfg.ClientAuthyourself in that case, or useServerConfig.
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:
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.