SSL/TLS Certificate Management in Go

SSL/TLS Certificate Management in Go

Go is one of the very few languages that ships a complete, production-grade TLS stack in its standard library. There is no OpenSSL binding to install, no C toolchain to fight with, and no third-party dependency required to generate a key, issue a certificate, verify a chain, or terminate TLS on a listening socket. Everything lives in crypto/tls, crypto/x509, crypto/x509/pkix and encoding/pem.

That convenience has a downside: because the API is so accessible, it is easy to write code that works without understanding what it actually guarantees. A server that loads a key pair with three lines of code is trivially easy to write; a server that rotates certificates without downtime, validates client identities, pins a peer's public key, and refuses obsolete protocol versions requires understanding the model beneath the API.

This article walks through that model from the ground up: key generation, certificate creation, private certificate authorities, HTTPS servers, hardened configurations, hot reload, SNI, mutual TLS, client-side trust decisions, chain verification, expiry monitoring, automatic ACME issuance, and testing. All code targets modern Go (1.22 and later) and uses only the standard library, with the single exception of golang.org/x/crypto/acme/autocert.

The vocabulary: what a certificate actually is

Before writing any code, it is worth being precise about the objects involved, because Go's type names map directly onto them.

  • Private key — an *rsa.PrivateKey, *ecdsa.PrivateKey or ed25519.PrivateKey. In Go these all satisfy the crypto.Signer interface, which is the abstraction you should program against.
  • Certificate — an X.509 structure binding a public key to a set of identities (DNS names, IP addresses, URIs), signed by an issuer. In Go this is *x509.Certificate once parsed, or a raw DER byte slice before parsing.
  • Certificate authority (CA) — a certificate with the IsCA basic constraint set and the KeyUsageCertSign usage, whose private key is used to sign other certificates.
  • Chain — the ordered list leaf → intermediate(s) → root. A server must send the leaf and any intermediates; it must not send the root, because the client already has it or does not trust it.
  • Key pair for TLStls.Certificate, which despite the name is a bundle: a chain of DER certificates plus the matching private key. This is the type crypto/tls actually consumes.

Two encodings appear constantly. DER is the binary ASN.1 serialization. PEM is DER wrapped in Base64 with -----BEGIN X----- armor. Files on disk are almost always PEM; the Go APIs almost always want DER. encoding/pem is the bridge.

Generating private keys

Start with the key, because the certificate is built around it. Rather than scattering algorithm choices across the codebase, centralize generation behind a small enumeration and return the crypto.Signer interface.

package pki

import (
	"crypto"
	"crypto/ecdsa"
	"crypto/ed25519"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/rsa"
	"fmt"
)

// KeyAlgorithm enumerates the key types this package can produce.
type KeyAlgorithm string

const (
	AlgorithmRSA2048   KeyAlgorithm = "rsa-2048"
	AlgorithmRSA4096   KeyAlgorithm = "rsa-4096"
	AlgorithmECDSAP256 KeyAlgorithm = "ecdsa-p256"
	AlgorithmECDSAP384 KeyAlgorithm = "ecdsa-p384"
	AlgorithmEd25519   KeyAlgorithm = "ed25519"
)

// GeneratePrivateKey returns a freshly generated key as a crypto.Signer,
// which is the interface every x509 signing function accepts.
func GeneratePrivateKey(algorithm KeyAlgorithm) (crypto.Signer, error) {
	switch algorithm {
	case AlgorithmRSA2048:
		return rsa.GenerateKey(rand.Reader, 2048)
	case AlgorithmRSA4096:
		return rsa.GenerateKey(rand.Reader, 4096)
	case AlgorithmECDSAP256:
		return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	case AlgorithmECDSAP384:
		return ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
	case AlgorithmEd25519:
		_, privateKey, err := ed25519.GenerateKey(rand.Reader)
		if err != nil {
			return nil, fmt.Errorf("generate ed25519 key: %w", err)
		}
		return privateKey, nil
	default:
		return nil, fmt.Errorf("unsupported key algorithm: %q", algorithm)
	}
}

Three observations. First, always use crypto/rand.Reader as the entropy source; math/rand in this position is a catastrophic bug, not a style issue. Second, ed25519.GenerateKey returns the public key first, which is a common source of accidental key swapping — the blank identifier here is deliberate, since the public key is recoverable from the private key via Public(). Third, note the return type: by returning crypto.Signer instead of a concrete type, the rest of the package never needs a type switch. x509.CreateCertificate takes a crypto.PublicKey and a crypto.Signer, so this interface flows through naturally.

On algorithm choice: P-256 is the sensible default for TLS server certificates in 2026. It is fast, universally supported, and produces small handshakes. RSA-2048 remains necessary only for legacy clients or when an external system requires it. Ed25519 is excellent but is still not accepted by every CA or every TLS peer, so reserve it for internal PKI where you control both ends.

Encoding to PEM

Keys and certificates need to reach disk. Marshal private keys with PKCS#8 (MarshalPKCS8PrivateKey) rather than the algorithm-specific formats: PKCS#8 is universal, works for RSA, ECDSA and Ed25519 alike, and produces the generic PRIVATE KEY PEM type that every modern tool reads.

package pki

import (
	"crypto"
	"crypto/x509"
	"encoding/pem"
	"fmt"
	"os"
)

const (
	pemTypeCertificate = "CERTIFICATE"
	pemTypePrivateKey  = "PRIVATE KEY"
)

// EncodePrivateKeyPEM serializes any supported key using PKCS#8.
func EncodePrivateKeyPEM(key crypto.Signer) ([]byte, error) {
	der, err := x509.MarshalPKCS8PrivateKey(key)
	if err != nil {
		return nil, fmt.Errorf("marshal pkcs8 private key: %w", err)
	}
	return pem.EncodeToMemory(&pem.Block{Type: pemTypePrivateKey, Bytes: der}), nil
}

// EncodeCertificatePEM wraps a single DER certificate in PEM armor.
func EncodeCertificatePEM(der []byte) []byte {
	return pem.EncodeToMemory(&pem.Block{Type: pemTypeCertificate, Bytes: der})
}

// EncodeChainPEM concatenates a chain, leaf first, in the order TLS expects.
func EncodeChainPEM(chain [][]byte) []byte {
	var out []byte
	for _, der := range chain {
		out = append(out, EncodeCertificatePEM(der)...)
	}
	return out
}

// WriteKeyPair persists a certificate chain and its key with correct permissions.
func WriteKeyPair(certPath, keyPath string, chain [][]byte, key crypto.Signer) error {
	keyPEM, err := EncodePrivateKeyPEM(key)
	if err != nil {
		return err
	}
	if err := os.WriteFile(certPath, EncodeChainPEM(chain), 0o644); err != nil {
		return fmt.Errorf("write certificate: %w", err)
	}
	// The private key must never be world-readable.
	if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
		return fmt.Errorf("write private key: %w", err)
	}
	return nil
}

The file mode on the key file is not cosmetic. A key written with 0o644 is readable by every local user and by every process in the same container. Go will happily load it; a security review will not.

Reading back is the inverse operation. The one subtlety is that x509.ParsePKCS8PrivateKey returns any, so an assertion to crypto.Signer is required.

// ParsePrivateKeyPEM decodes a PEM-encoded PKCS#8 private key.
func ParsePrivateKeyPEM(data []byte) (crypto.Signer, error) {
	block, _ := pem.Decode(data)
	if block == nil {
		return nil, fmt.Errorf("no PEM block found")
	}
	if block.Type != pemTypePrivateKey {
		return nil, fmt.Errorf("unexpected PEM type %q, want %q", block.Type, pemTypePrivateKey)
	}
	parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
	if err != nil {
		return nil, fmt.Errorf("parse pkcs8 private key: %w", err)
	}
	signer, ok := parsed.(crypto.Signer)
	if !ok {
		return nil, fmt.Errorf("key of type %T does not implement crypto.Signer", parsed)
	}
	return signer, nil
}

Creating a self-signed certificate

A self-signed certificate is one where the issuer and the subject are the same entity: the certificate is signed by its own key. It is the right tool for local development and for internal services whose trust is distributed out of band, and the wrong tool for anything a browser will visit.

Every certificate needs a serial number. It must be unique per issuer and unpredictable; the standard recipe is a random 128-bit integer.

package pki

import (
	"crypto/rand"
	"crypto/x509"
	"crypto/x509/pkix"
	"fmt"
	"math/big"
	"net"
	"net/url"
	"strings"
	"time"
)

// newSerialNumber returns a cryptographically random 128-bit serial number.
func newSerialNumber() (*big.Int, error) {
	limit := new(big.Int).Lsh(big.NewInt(1), 128)
	serial, err := rand.Int(rand.Reader, limit)
	if err != nil {
		return nil, fmt.Errorf("generate serial number: %w", err)
	}
	return serial, nil
}

// applySubjectAltNames sorts each host string into the correct SAN slot.
func applySubjectAltNames(template *x509.Certificate, hosts []string) error {
	for _, host := range hosts {
		switch {
		case net.ParseIP(host) != nil:
			template.IPAddresses = append(template.IPAddresses, net.ParseIP(host))
		case strings.Contains(host, "://"):
			parsed, err := url.Parse(host)
			if err != nil {
				return fmt.Errorf("parse URI SAN %q: %w", host, err)
			}
			template.URIs = append(template.URIs, parsed)
		default:
			template.DNSNames = append(template.DNSNames, host)
		}
	}
	return nil
}

The Subject Alternative Name extension deserves emphasis. Since Go 1.15 the legacy Common Name fallback is gone: a certificate whose hostname appears only in Subject.CommonName and not in DNSNames will fail verification with x509.HostnameError. Set the CN if you like it for human readability, but the SANs are what matter.

// SelfSignedOptions configures a standalone certificate.
type SelfSignedOptions struct {
	Hosts        []string      // DNS names, IPs and URIs to embed as SANs
	Organization string
	ValidFor     time.Duration
	Algorithm    KeyAlgorithm
}

// CreateSelfSigned generates a key and a certificate signed by that same key.
func CreateSelfSigned(opts SelfSignedOptions) (certPEM, keyPEM []byte, err error) {
	if len(opts.Hosts) == 0 {
		return nil, nil, fmt.Errorf("at least one host is required")
	}
	if opts.Algorithm == "" {
		opts.Algorithm = AlgorithmECDSAP256
	}
	if opts.ValidFor == 0 {
		opts.ValidFor = 90 * 24 * time.Hour
	}

	key, err := GeneratePrivateKey(opts.Algorithm)
	if err != nil {
		return nil, nil, err
	}
	serial, err := newSerialNumber()
	if err != nil {
		return nil, nil, err
	}

	now := time.Now()
	template := &x509.Certificate{
		SerialNumber: serial,
		Subject: pkix.Name{
			Organization: []string{opts.Organization},
			CommonName:   opts.Hosts[0],
		},
		// Backdate slightly to tolerate clock skew between peers.
		NotBefore:             now.Add(-5 * time.Minute),
		NotAfter:              now.Add(opts.ValidFor),
		KeyUsage:              x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
		ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
		BasicConstraintsValid: true,
	}
	if err := applySubjectAltNames(template, opts.Hosts); err != nil {
		return nil, nil, err
	}

	// parent == template and signer key == subject key: this is what
	// makes the certificate self-signed.
	der, err := x509.CreateCertificate(rand.Reader, template, template, key.Public(), key)
	if err != nil {
		return nil, nil, fmt.Errorf("create certificate: %w", err)
	}

	keyPEM, err = EncodePrivateKeyPEM(key)
	if err != nil {
		return nil, nil, err
	}
	return EncodeCertificatePEM(der), keyPEM, nil
}

The signature of x509.CreateCertificate is worth memorizing, because every certificate you ever issue goes through it:

func CreateCertificate(
	rand io.Reader,
	template, parent *Certificate,
	pub any,
	priv any,
) ([]byte, error)

The template describes the certificate being created. The parent is the issuer's certificate — its Subject becomes the new certificate's Issuer. The pub is the public key of the subject. The priv is the private key of the issuer. Mixing up the last two is the single most common mistake in this API, and the result is a certificate whose signature nothing can verify.

Also note KeyUsageKeyEncipherment: it is required for RSA key-exchange cipher suites under TLS 1.2 and harmless otherwise. With ECDSA keys it is meaningless but not an error. BasicConstraintsValid: true is mandatory — without it the IsCA field is not encoded at all, and verifiers will treat the constraint as absent.

Building a private certificate authority

Self-signed certificates do not scale past a single service, because each one must be individually trusted by every client. A private CA inverts that: clients trust one root, and the root issues as many leaf certificates as you need. This is the correct architecture for service-to-service TLS inside a cluster.

package pki

import (
	"crypto"
	"crypto/rand"
	"crypto/tls"
	"crypto/x509"
	"crypto/x509/pkix"
	"fmt"
	"time"
)

// CA holds a certificate authority's certificate and signing key.
type CA struct {
	Certificate *x509.Certificate
	PrivateKey  crypto.Signer
	certDER     []byte
}

// NewCA creates a self-signed root certificate authority.
func NewCA(commonName, organization string, validFor time.Duration) (*CA, error) {
	key, err := GeneratePrivateKey(AlgorithmECDSAP384)
	if err != nil {
		return nil, err
	}
	serial, err := newSerialNumber()
	if err != nil {
		return nil, err
	}

	now := time.Now()
	template := &x509.Certificate{
		SerialNumber: serial,
		Subject: pkix.Name{
			CommonName:   commonName,
			Organization: []string{organization},
		},
		NotBefore: now.Add(-5 * time.Minute),
		NotAfter:  now.Add(validFor),
		// A CA signs certificates and revocation lists; it does not
		// terminate TLS itself.
		KeyUsage:              x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature,
		BasicConstraintsValid: true,
		IsCA:                  true,
		// MaxPathLenZero forbids intermediate CAs beneath this root.
		MaxPathLen:     0,
		MaxPathLenZero: true,
	}

	der, err := x509.CreateCertificate(rand.Reader, template, template, key.Public(), key)
	if err != nil {
		return nil, fmt.Errorf("create CA certificate: %w", err)
	}
	// Re-parse so that computed fields (RawSubject, signature, and the
	// authority key identifier) are populated correctly.
	certificate, err := x509.ParseCertificate(der)
	if err != nil {
		return nil, fmt.Errorf("parse CA certificate: %w", err)
	}

	return &CA{Certificate: certificate, PrivateKey: key, certDER: der}, nil
}

The re-parse step matters. The *x509.Certificate you pass in as a template is a partially populated struct; the one you get back from ParseCertificate reflects what was actually encoded, including RawSubject, which CreateCertificate reads when it fills in the child's issuer field. Signing with the un-parsed template produces subtly broken chains.

Issuing a leaf is now a matter of changing the parent and the constraints.

// LeafOptions describes a certificate to be issued by the CA.
type LeafOptions struct {
	Hosts       []string
	CommonName  string
	ValidFor    time.Duration
	Algorithm   KeyAlgorithm
	ForClient   bool // true issues a client certificate instead of a server one
}

// IssueLeaf generates a key and signs a leaf certificate for it.
func (ca *CA) IssueLeaf(opts LeafOptions) (tls.Certificate, error) {
	if opts.Algorithm == "" {
		opts.Algorithm = AlgorithmECDSAP256
	}
	if opts.ValidFor == 0 {
		opts.ValidFor = 30 * 24 * time.Hour
	}

	key, err := GeneratePrivateKey(opts.Algorithm)
	if err != nil {
		return tls.Certificate{}, err
	}
	serial, err := newSerialNumber()
	if err != nil {
		return tls.Certificate{}, err
	}

	usage := x509.ExtKeyUsageServerAuth
	if opts.ForClient {
		usage = x509.ExtKeyUsageClientAuth
	}

	now := time.Now()
	template := &x509.Certificate{
		SerialNumber:          serial,
		Subject:               pkix.Name{CommonName: opts.CommonName},
		NotBefore:             now.Add(-5 * time.Minute),
		NotAfter:              now.Add(opts.ValidFor),
		KeyUsage:              x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
		ExtKeyUsage:           []x509.ExtKeyUsage{usage},
		BasicConstraintsValid: true,
		IsCA:                  false,
	}
	if err := applySubjectAltNames(template, opts.Hosts); err != nil {
		return tls.Certificate{}, err
	}

	// parent is the CA certificate; the signing key is the CA's key.
	der, err := x509.CreateCertificate(rand.Reader, template, ca.Certificate, key.Public(), ca.PrivateKey)
	if err != nil {
		return tls.Certificate{}, fmt.Errorf("sign leaf certificate: %w", err)
	}
	leaf, err := x509.ParseCertificate(der)
	if err != nil {
		return tls.Certificate{}, fmt.Errorf("parse leaf certificate: %w", err)
	}

	return tls.Certificate{
		// Leaf first, then the issuer chain. The root is included here
		// only because this CA is its own root and clients may not have
		// it; in a public PKI you would omit the root.
		Certificate: [][]byte{der, ca.certDER},
		PrivateKey:  key,
		Leaf:        leaf,
	}, nil
}

// PoolPEM returns the CA certificate in PEM form, suitable for a trust store.
func (ca *CA) PoolPEM() []byte {
	return EncodeCertificatePEM(ca.certDER)
}

Setting Leaf on the returned tls.Certificate is a small but real optimization: if it is nil, crypto/tls re-parses the leaf DER on every handshake that needs to inspect it.

Serving HTTPS

With a key pair on disk, the minimal server is short:

package main

import (
	"log"
	"net/http"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("ok"))
	})

	log.Fatal(http.ListenAndServeTLS(":8443", "server.crt", "server.key", mux))
}

This is fine for a demo and inadequate for production, because it exposes no control over the TLS configuration and no control over timeouts. The real version constructs an *http.Server explicitly:

package main

import (
	"context"
	"crypto/tls"
	"errors"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func newServer(handler http.Handler, tlsConfig *tls.Config) *http.Server {
	return &http.Server{
		Addr:      ":8443",
		Handler:   handler,
		TLSConfig: tlsConfig,
		// A slow TLS handshake is a denial-of-service vector; bound it.
		ReadHeaderTimeout: 10 * time.Second,
		ReadTimeout:       30 * time.Second,
		WriteTimeout:      60 * time.Second,
		IdleTimeout:       120 * time.Second,
	}
}

func main() {
	certificate, err := tls.LoadX509KeyPair("server.crt", "server.key")
	if err != nil {
		log.Fatalf("load key pair: %v", err)
	}

	server := newServer(handler(), &tls.Config{
		Certificates: []tls.Certificate{certificate},
		MinVersion:   tls.VersionTLS12,
	})

	go func() {
		// Empty strings: the certificates come from TLSConfig instead.
		if err := server.ListenAndServeTLS("", ""); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("serve: %v", err)
		}
	}()

	stop := make(chan os.Signal, 1)
	signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
	<-stop

	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
	defer cancel()
	if err := server.Shutdown(ctx); err != nil {
		log.Printf("shutdown: %v", err)
	}
}

The empty-string arguments to ListenAndServeTLS are the idiom for "use whatever is already in TLSConfig". If you pass file paths there and populate Certificates, the file paths win and your carefully built configuration is silently overridden.

Hardening the TLS configuration

Go's defaults are good, but "good" is not the same as "explicit". A configuration you can point at during an audit is worth the twenty lines it costs.

// hardenedServerConfig returns a conservative TLS configuration suitable
// for public-facing servers.
func hardenedServerConfig(getCertificate func(*tls.ClientHelloInfo) (*tls.Certificate, error)) *tls.Config {
	return &tls.Config{
		GetCertificate: getCertificate,

		// TLS 1.0 and 1.1 are deprecated by RFC 8996. TLS 1.2 is the
		// floor; raise to VersionTLS13 when no legacy clients remain.
		MinVersion: tls.VersionTLS12,
		MaxVersion: tls.VersionTLS13,

		// CipherSuites only affects TLS 1.2. TLS 1.3 suites are fixed
		// by the protocol and cannot be configured in Go.
		CipherSuites: []uint16{
			tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
			tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
			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,
		},

		CurvePreferences: []tls.CurveID{
			tls.X25519,
			tls.CurveP256,
			tls.CurveP384,
		},

		// Advertise HTTP/2 and HTTP/1.1 over ALPN.
		NextProtos: []string{"h2", "http/1.1"},
	}
}

Several points repay attention. PreferServerCipherSuites is deprecated and ignored since Go 1.17 — the runtime now orders suites itself based on hardware AES support, which is a better decision than any static list. Setting CipherSuites at all is optional; Go's default list already excludes everything known to be broken. What you cannot do is weaken TLS 1.3, and that is by design.

To inspect what a running server actually negotiated, read the connection state inside a handler:

func tlsInfoHandler(w http.ResponseWriter, r *http.Request) {
	state := r.TLS
	if state == nil {
		http.Error(w, "not a TLS connection", http.StatusBadRequest)
		return
	}
	fmt.Fprintf(w, "version: %s\n", tls.VersionName(state.Version))
	fmt.Fprintf(w, "cipher suite: %s\n", tls.CipherSuiteName(state.CipherSuite))
	fmt.Fprintf(w, "negotiated protocol: %s\n", state.NegotiatedProtocol)
	fmt.Fprintf(w, "server name: %s\n", state.ServerName)
	fmt.Fprintf(w, "resumed session: %t\n", state.DidResume)
}

Hot reload: rotating certificates without downtime

Certificates expire. With ACME issuance the renewal interval is measured in weeks, and restarting the process to pick up a new file is unacceptable for a service handling live traffic. The solution is GetCertificate: a callback that crypto/tls invokes on every handshake. Whatever it returns is what gets used, so swapping the value behind a mutex swaps the certificate for all subsequent connections instantly, while existing connections continue undisturbed.

package tlsutil

import (
	"context"
	"crypto/tls"
	"crypto/x509"
	"fmt"
	"log/slog"
	"os"
	"os/signal"
	"sync/atomic"
	"syscall"
	"time"
)

// Reloader serves a key pair from disk and can atomically swap it.
type Reloader struct {
	certPath string
	keyPath  string
	current  atomic.Pointer[tls.Certificate]
	logger   *slog.Logger
}

// NewReloader loads the key pair immediately so that startup fails fast
// on a bad configuration.
func NewReloader(certPath, keyPath string, logger *slog.Logger) (*Reloader, error) {
	r := &Reloader{certPath: certPath, keyPath: keyPath, logger: logger}
	if err := r.Reload(); err != nil {
		return nil, err
	}
	return r, nil
}

// Reload reads the files from disk and atomically replaces the cached pair.
func (r *Reloader) Reload() error {
	certificate, err := tls.LoadX509KeyPair(r.certPath, r.keyPath)
	if err != nil {
		return fmt.Errorf("load key pair from %s: %w", r.certPath, err)
	}
	// Parse the leaf once, up front, rather than on every handshake.
	leaf, err := x509.ParseCertificate(certificate.Certificate[0])
	if err != nil {
		return fmt.Errorf("parse leaf: %w", err)
	}
	certificate.Leaf = leaf

	r.current.Store(&certificate)
	r.logger.Info("certificate reloaded",
		"subject", leaf.Subject.CommonName,
		"not_after", leaf.NotAfter.Format(time.RFC3339))
	return nil
}

// GetCertificate satisfies the tls.Config.GetCertificate signature.
func (r *Reloader) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
	certificate := r.current.Load()
	if certificate == nil {
		return nil, fmt.Errorf("no certificate loaded")
	}
	return certificate, nil
}

// GetClientCertificate satisfies tls.Config.GetClientCertificate, so the
// same reloader can drive a client that presents a rotating identity.
func (r *Reloader) GetClientCertificate(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
	return r.GetCertificate(nil)
}

atomic.Pointer[tls.Certificate] is a better fit here than a mutex: reads happen on every handshake and must never contend, while writes happen a few times a year. The generic atomic pointer gives lock-free reads with no risk of a torn value.

Two triggers are worth wiring up. A signal handler covers the classic SIGHUP convention, and a polling loop covers orchestrators that rewrite a mounted secret without signalling anything.

// WatchSignals reloads the key pair whenever SIGHUP arrives.
func (r *Reloader) WatchSignals(ctx context.Context) {
	hup := make(chan os.Signal, 1)
	signal.Notify(hup, syscall.SIGHUP)
	defer signal.Stop(hup)

	for {
		select {
		case <-ctx.Done():
			return
		case <-hup:
			if err := r.Reload(); err != nil {
				// A failed reload must not take down the server: keep
				// serving the previous, still-valid certificate.
				r.logger.Error("reload failed, keeping previous certificate", "error", err)
			}
		}
	}
}

// WatchPeriodically re-reads the files on an interval, comparing the
// serial number to avoid noisy logs when nothing has changed.
func (r *Reloader) WatchPeriodically(ctx context.Context, interval time.Duration) {
	ticker := time.NewTicker(interval)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			previous := r.current.Load()
			if err := r.Reload(); err != nil {
				r.logger.Error("periodic reload failed", "error", err)
				continue
			}
			updated := r.current.Load()
			if previous != nil && previous.Leaf.SerialNumber.Cmp(updated.Leaf.SerialNumber) == 0 {
				r.logger.Debug("certificate unchanged")
			}
		}
	}
}

The critical design decision is in the error path: a reload that fails must leave the previous certificate in place. If the deployment pipeline writes a truncated file, a server that clears its cached certificate on error stops serving entirely; a server that keeps the old one degrades to "slightly stale" and stays up.

SNI: many certificates, one listener

Server Name Indication lets a client announce the hostname it wants during the handshake, before any HTTP request exists. GetCertificate receives that hostname in ClientHelloInfo.ServerName, which is all a virtual-hosting TLS terminator needs.

package tlsutil

import (
	"crypto/tls"
	"crypto/x509"
	"fmt"
	"strings"
	"sync"
)

// SNIRegistry maps hostnames to certificates, with wildcard support.
type SNIRegistry struct {
	mu           sync.RWMutex
	exact        map[string]*tls.Certificate
	wildcard     map[string]*tls.Certificate // keyed by parent domain
	fallback     *tls.Certificate
}

func NewSNIRegistry() *SNIRegistry {
	return &SNIRegistry{
		exact:    make(map[string]*tls.Certificate),
		wildcard: make(map[string]*tls.Certificate),
	}
}

// Register indexes a certificate under every DNS name it carries.
func (s *SNIRegistry) Register(certificate *tls.Certificate) error {
	if certificate.Leaf == nil {
		leaf, err := x509.ParseCertificate(certificate.Certificate[0])
		if err != nil {
			return fmt.Errorf("parse leaf: %w", err)
		}
		certificate.Leaf = leaf
	}

	s.mu.Lock()
	defer s.mu.Unlock()
	for _, name := range certificate.Leaf.DNSNames {
		name = strings.ToLower(name)
		if parent, found := strings.CutPrefix(name, "*."); found {
			s.wildcard[parent] = certificate
			continue
		}
		s.exact[name] = certificate
	}
	return nil
}

// SetFallback assigns the certificate used when SNI is absent or unknown.
func (s *SNIRegistry) SetFallback(certificate *tls.Certificate) {
	s.mu.Lock()
	s.fallback = certificate
	s.mu.Unlock()
}

// GetCertificate resolves a ClientHello to a certificate.
func (s *SNIRegistry) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
	name := strings.ToLower(strings.TrimSuffix(hello.ServerName, "."))

	s.mu.RLock()
	defer s.mu.RUnlock()

	if certificate, ok := s.exact[name]; ok {
		return certificate, nil
	}
	// A wildcard matches exactly one label: *.example.com covers
	// api.example.com but not a.b.example.com.
	if _, parent, found := strings.Cut(name, "."); found {
		if certificate, ok := s.wildcard[parent]; ok {
			return certificate, nil
		}
	}
	if s.fallback != nil {
		return s.fallback, nil
	}
	return nil, fmt.Errorf("no certificate configured for server name %q", name)
}

A subtlety: ClientHelloInfo also exposes SupportsCertificate, which reports whether a given certificate is actually acceptable to the client given its advertised signature algorithms and cipher suites. If you serve both an ECDSA and an RSA certificate for the same name to support very old clients, use it to choose between them:

// pick returns the first certificate the client can actually use.
func pick(hello *tls.ClientHelloInfo, candidates ...*tls.Certificate) (*tls.Certificate, error) {
	for _, candidate := range candidates {
		if err := hello.SupportsCertificate(candidate); err == nil {
			return candidate, nil
		}
	}
	return nil, fmt.Errorf("no mutually supported certificate for %q", hello.ServerName)
}

Returning an error from GetCertificate aborts the handshake with an internal error alert. That is the correct behaviour for an unknown hostname: it is better than serving a certificate the client will reject with a confusing name mismatch.

Mutual TLS: authenticating the client

In mutual TLS the client presents a certificate too, and the server verifies it against a trust pool. This turns the transport layer into an authentication layer — no bearer tokens, no shared secrets in configuration, and revocation handled by rotating the client's certificate.

The server side needs three additions: a ClientCAs pool, a ClientAuth policy, and — for anything beyond "the CA vouches for you" — a VerifyPeerCertificate hook.

package mtls

import (
	"crypto/tls"
	"crypto/x509"
	"fmt"
	"os"
	"slices"
)

// LoadCertPool builds an x509.CertPool from one or more PEM bundles.
func LoadCertPool(paths ...string) (*x509.CertPool, error) {
	pool := x509.NewCertPool()
	for _, path := range paths {
		data, err := os.ReadFile(path)
		if err != nil {
			return nil, fmt.Errorf("read CA bundle %s: %w", path, err)
		}
		if !pool.AppendCertsFromPEM(data) {
			// AppendCertsFromPEM reports only a boolean; it never says
			// which certificate failed, so name the file at least.
			return nil, fmt.Errorf("no valid certificates found in %s", path)
		}
	}
	return pool, nil
}

// ServerConfig builds a configuration that requires and verifies a
// client certificate, and additionally restricts access to a set of
// allowed subject common names.
func ServerConfig(
	getCertificate func(*tls.ClientHelloInfo) (*tls.Certificate, error),
	clientCAs *x509.CertPool,
	allowedClients []string,
) *tls.Config {
	return &tls.Config{
		GetCertificate: getCertificate,
		MinVersion:     tls.VersionTLS12,
		ClientCAs:      clientCAs,
		ClientAuth:     tls.RequireAndVerifyClientCert,

		// Runs after the standard chain verification has succeeded.
		VerifyPeerCertificate: func(rawCerts [][]byte, chains [][]*x509.Certificate) error {
			if len(chains) == 0 || len(chains[0]) == 0 {
				return fmt.Errorf("no verified chain presented")
			}
			leaf := chains[0][0]
			if !slices.Contains(allowedClients, leaf.Subject.CommonName) {
				return fmt.Errorf("client %q is not authorized", leaf.Subject.CommonName)
			}
			return nil
		},
	}
}

The ClientAuth values form a ladder, and only the top rung is an authentication mechanism:

  • NoClientCert — never ask. The default.
  • RequestClientCert — ask, accept anything, verify nothing.
  • RequireAnyClientCert — demand a certificate, verify nothing. Provides no security.
  • VerifyClientCertIfGiven — verify if presented, allow the connection if not. Useful for optional client auth, dangerous if handlers forget to check.
  • RequireAndVerifyClientCert — demand a certificate and verify it against ClientCAs. This is the one you want.

The signature of VerifyPeerCertificate is easy to misread. When standard verification is enabled, the verifiedChains argument contains chains that already passed validation, and returning nil accepts the connection. When verification is disabled (InsecureSkipVerify: true on a client, or RequireAnyClientCert on a server), verifiedChains is nil and rawCerts holds unvalidated DER — the hook is then your only line of defence, and you must do all the work yourself.

Handlers can read the authenticated identity straight off the request:

func whoAmI(w http.ResponseWriter, r *http.Request) {
	if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 {
		http.Error(w, "client certificate required", http.StatusUnauthorized)
		return
	}
	leaf := r.TLS.PeerCertificates[0]
	fmt.Fprintf(w, "authenticated as %s\n", leaf.Subject.CommonName)
	fmt.Fprintf(w, "issued by %s\n", leaf.Issuer.CommonName)
	fmt.Fprintf(w, "expires %s\n", leaf.NotAfter.Format(time.RFC3339))
}

The client side mirrors it. Note GetClientCertificate rather than a static Certificates slice, so client identities can rotate too.

// NewClient builds an http.Client that presents a certificate and trusts
// only the given roots.
func NewClient(reloader *tlsutil.Reloader, roots *x509.CertPool) *http.Client {
	transport := &http.Transport{
		TLSClientConfig: &tls.Config{
			GetClientCertificate: reloader.GetClientCertificate,
			RootCAs:              roots,
			MinVersion:           tls.VersionTLS12,
		},
		ForceAttemptHTTP2:   true,
		MaxIdleConns:        100,
		IdleConnTimeout:     90 * time.Second,
		TLSHandshakeTimeout: 10 * time.Second,
	}
	return &http.Client{Transport: transport, Timeout: 30 * time.Second}
}

Client-side trust: custom roots and public key pinning

Setting RootCAs replaces the system trust store; it does not extend it. If a client needs to reach both an internal service signed by your CA and a public API signed by a commercial CA, start from the system pool and add to it:

// systemPoolWith returns the OS trust store extended with extra PEM roots.
func systemPoolWith(extraPEM []byte) (*x509.CertPool, error) {
	pool, err := x509.SystemCertPool()
	if err != nil {
		// On Windows this can fail; fall back to an empty pool rather
		// than silently trusting nothing on some platforms and
		// everything on others.
		return nil, fmt.Errorf("load system cert pool: %w", err)
	}
	// SystemCertPool returns a copy, so mutating it is safe.
	if !pool.AppendCertsFromPEM(extraPEM) {
		return nil, fmt.Errorf("no valid certificates in extra roots")
	}
	return pool, nil
}

Public key pinning goes one step further: even a valid chain from a trusted CA is rejected unless the peer's public key matches a known fingerprint. This defends against a compromised or coerced CA. Pin the SubjectPublicKeyInfo hash, not the certificate hash, so that routine renewals with the same key do not break the pin.

package pinning

import (
	"crypto/sha256"
	"crypto/tls"
	"crypto/x509"
	"encoding/base64"
	"fmt"
)

// SPKIPin computes the base64-encoded SHA-256 of a certificate's
// SubjectPublicKeyInfo, the same value used by HPKP and by curl --pinnedpubkey.
func SPKIPin(certificate *x509.Certificate) string {
	sum := sha256.Sum256(certificate.RawSubjectPublicKeyInfo)
	return base64.StdEncoding.EncodeToString(sum[:])
}

// VerifierFor returns a VerifyPeerCertificate hook enforcing a pin set.
// Standard verification still runs first: pinning narrows trust, it does
// not replace it.
func VerifierFor(pins []string) func([][]byte, [][]*x509.Certificate) error {
	allowed := make(map[string]struct{}, len(pins))
	for _, pin := range pins {
		allowed[pin] = struct{}{}
	}

	return func(_ [][]byte, verifiedChains [][]*x509.Certificate) error {
		for _, chain := range verifiedChains {
			// Any certificate in the chain may carry the pin, which
			// allows pinning an intermediate rather than the leaf.
			for _, certificate := range chain {
				if _, ok := allowed[SPKIPin(certificate)]; ok {
					return nil
				}
			}
		}
		return fmt.Errorf("no certificate in any verified chain matches the configured pins")
	}
}

// PinnedClientConfig combines normal verification with pin enforcement.
func PinnedClientConfig(roots *x509.CertPool, pins []string) *tls.Config {
	return &tls.Config{
		RootCAs:               roots,
		MinVersion:            tls.VersionTLS12,
		VerifyPeerCertificate: VerifierFor(pins),
	}
}

Pinning is operationally dangerous and should be deployed only with a backup pin — the SPKI hash of a key you have generated but not yet put into service. Without one, losing the pinned key means every pinned client is locked out until it is updated.

Retrieving the current pin for a host is a useful CLI:

// FetchPins dials a host and prints the SPKI pin of each presented certificate.
func FetchPins(address string) ([]string, error) {
	conn, err := tls.Dial("tcp", address, &tls.Config{MinVersion: tls.VersionTLS12})
	if err != nil {
		return nil, fmt.Errorf("dial %s: %w", address, err)
	}
	defer conn.Close()

	var pins []string
	for _, certificate := range conn.ConnectionState().PeerCertificates {
		pins = append(pins, fmt.Sprintf("%s -> %s", certificate.Subject.CommonName, SPKIPin(certificate)))
	}
	return pins, nil
}

Verifying a chain manually

Sometimes you need to validate a certificate outside a handshake: an admission controller checking an uploaded bundle, a CI job asserting that a deployment artifact is well formed, a debugging tool. (*x509.Certificate).Verify is the entry point.

package verify

import (
	"crypto/x509"
	"encoding/pem"
	"fmt"
	"time"
)

// ParseChainPEM decodes a concatenated PEM bundle into certificates,
// preserving order (leaf first).
func ParseChainPEM(data []byte) ([]*x509.Certificate, error) {
	var chain []*x509.Certificate
	rest := data
	for {
		var block *pem.Block
		block, rest = pem.Decode(rest)
		if block == nil {
			break
		}
		if block.Type != "CERTIFICATE" {
			continue
		}
		certificate, err := x509.ParseCertificate(block.Bytes)
		if err != nil {
			return nil, fmt.Errorf("parse certificate: %w", err)
		}
		chain = append(chain, certificate)
	}
	if len(chain) == 0 {
		return nil, fmt.Errorf("no certificates found")
	}
	return chain, nil
}

// VerifyChain checks a bundle against a set of roots for a given hostname
// at a given time.
func VerifyChain(bundle []byte, roots *x509.CertPool, hostname string, at time.Time) error {
	chain, err := ParseChainPEM(bundle)
	if err != nil {
		return err
	}

	intermediates := x509.NewCertPool()
	for _, certificate := range chain[1:] {
		intermediates.AddCert(certificate)
	}

	opts := x509.VerifyOptions{
		Roots:         roots,
		Intermediates: intermediates,
		DNSName:       hostname,
		CurrentTime:   at,
		KeyUsages:     []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
	}

	verifiedChains, err := chain[0].Verify(opts)
	if err != nil {
		return fmt.Errorf("verify %s: %w", hostname, err)
	}
	for _, verified := range verifiedChains {
		for depth, certificate := range verified {
			fmt.Printf("  [%d] %s (expires %s)\n",
				depth, certificate.Subject.CommonName, certificate.NotAfter.Format(time.DateOnly))
		}
	}
	return nil
}

The CurrentTime field is what makes this testable: pass a fixed instant and a certificate's validity window becomes deterministic, which is how you write a unit test for expiry handling without sleeping. Leaving it zero means "now".

Errors from Verify are typed, and distinguishing them produces far better diagnostics than a generic wrapped string:

func explain(err error) string {
	var unknownAuthority x509.UnknownAuthorityError
	var hostnameError x509.HostnameError
	var invalid x509.CertificateInvalidError

	switch {
	case errors.As(err, &unknownAuthority):
		return "the issuing CA is not in the trust store"
	case errors.As(err, &hostnameError):
		return "the certificate does not list the requested hostname in its SANs"
	case errors.As(err, &invalid):
		switch invalid.Reason {
		case x509.Expired:
			return "the certificate is expired or not yet valid"
		case x509.CANotAuthorizedForThisName:
			return "a CA in the chain is name-constrained and does not cover this name"
		case x509.IncompatibleUsage:
			return "the certificate's extended key usage does not permit this use"
		case x509.TooManyIntermediates:
			return "the chain exceeds the issuer's path length constraint"
		}
		return "the certificate is invalid: " + invalid.Detail
	default:
		return err.Error()
	}
}

Monitoring expiry

The most common TLS outage is not a cryptographic failure. It is a certificate nobody renewed. Monitoring is therefore not optional, and in Go it is about thirty lines.

package monitor

import (
	"context"
	"crypto/tls"
	"fmt"
	"net"
	"sync"
	"time"
)

// Status describes the certificate a host is currently serving.
type Status struct {
	Host       string
	Subject    string
	Issuer     string
	NotAfter   time.Time
	DaysLeft   int
	Err        error
}

// Check dials a host and reports on the leaf certificate it presents.
// InsecureSkipVerify is set deliberately: the goal is to report on the
// certificate even when it is already invalid, which is exactly the case
// a monitor must not be blind to.
func Check(ctx context.Context, host string) Status {
	status := Status{Host: host}

	dialer := &tls.Dialer{
		NetDialer: &net.Dialer{Timeout: 5 * time.Second},
		Config: &tls.Config{
			InsecureSkipVerify: true,
			MinVersion:         tls.VersionTLS12,
		},
	}
	conn, err := dialer.DialContext(ctx, "tcp", host)
	if err != nil {
		status.Err = fmt.Errorf("dial %s: %w", host, err)
		return status
	}
	defer conn.Close()

	state := conn.(*tls.Conn).ConnectionState()
	if len(state.PeerCertificates) == 0 {
		status.Err = fmt.Errorf("%s presented no certificate", host)
		return status
	}

	leaf := state.PeerCertificates[0]
	status.Subject = leaf.Subject.CommonName
	status.Issuer = leaf.Issuer.CommonName
	status.NotAfter = leaf.NotAfter
	status.DaysLeft = int(time.Until(leaf.NotAfter).Hours() / 24)
	return status
}

// CheckAll fans out across hosts and collects the results.
func CheckAll(ctx context.Context, hosts []string) []Status {
	results := make([]Status, len(hosts))
	var wg sync.WaitGroup
	for i, host := range hosts {
		wg.Add(1)
		go func() {
			defer wg.Done()
			results[i] = Check(ctx, host)
		}()
	}
	wg.Wait()
	return results
}

The InsecureSkipVerify here is one of the very few justified uses of that flag. A monitor whose dial fails on an expired certificate cannot report that the certificate is expired — it reports a connection error, which is precisely the information you already had. Every other appearance of InsecureSkipVerify: true in a codebase deserves scrutiny.

The same idea applies to certificates you are serving. Expose remaining lifetime as a metric and alert on it long before it matters:

// ExpiryGauge reports the seconds remaining on the served certificate,
// suitable for a Prometheus gauge.
func (r *Reloader) SecondsUntilExpiry() float64 {
	certificate := r.current.Load()
	if certificate == nil || certificate.Leaf == nil {
		return 0
	}
	return time.Until(certificate.Leaf.NotAfter).Seconds()
}

Automatic certificates with ACME

For internet-facing services, issuing certificates by hand is a solved problem: ACME and Let's Encrypt automate it, and golang.org/x/crypto/acme/autocert integrates directly with crypto/tls through the same GetCertificate hook used above.

go get golang.org/x/crypto/acme/autocert
package main

import (
	"crypto/tls"
	"log"
	"net/http"
	"time"

	"golang.org/x/crypto/acme"
	"golang.org/x/crypto/acme/autocert"
)

func main() {
	manager := &autocert.Manager{
		Prompt: autocert.AcceptTOS,
		// The cache must be persistent and shared across restarts, or
		// you will hit Let's Encrypt rate limits within a day.
		Cache: autocert.DirCache("/var/lib/acme"),
		// Never leave HostPolicy nil in production: an open policy will
		// request a certificate for any hostname pointed at your server.
		HostPolicy: autocert.HostWhitelist("example.com", "www.example.com"),
		Email:      "ops@example.com",
		Client:     &acme.Client{DirectoryURL: acme.LetsEncryptURL},
	}

	tlsConfig := manager.TLSConfig()
	tlsConfig.MinVersion = tls.VersionTLS12

	server := &http.Server{
		Addr:              ":443",
		Handler:           handler(),
		TLSConfig:         tlsConfig,
		ReadHeaderTimeout: 10 * time.Second,
	}

	// Port 80 serves the HTTP-01 challenge and redirects everything else.
	go func() {
		challengeServer := &http.Server{
			Addr:              ":80",
			Handler:           manager.HTTPHandler(nil),
			ReadHeaderTimeout: 10 * time.Second,
		}
		log.Fatal(challengeServer.ListenAndServe())
	}()

	log.Fatal(server.ListenAndServeTLS("", ""))
}

Three failure modes account for most autocert incidents. An ephemeral cache: DirCache pointed at a container's writable layer means every deployment re-requests certificates, and Let's Encrypt's duplicate-certificate limit (five per week per name set) will lock you out. Mount a volume, or implement the autocert.Cache interface over shared storage. A nil HostPolicy: anyone who points a DNS record at your IP triggers an issuance attempt on your account. Testing against production: use https://acme-staging-v02.api.letsencrypt.org/directory as the DirectoryURL while developing, since staging has far looser limits and issues untrusted certificates that are perfect for verifying the plumbing.

For hosts that are not publicly reachable, the HTTP-01 challenge is unusable and DNS-01 is required. autocert supports it only through the lower-level acme.Client; in practice, an external tool such as lego or certbot writing to a directory that a Reloader watches is the simpler architecture, and it keeps ACME logic out of your service entirely.

Testing TLS code

TLS code is easy to test badly — mocking the handshake tests nothing. The right approach is to run a real TLS server on a loopback socket with a real, throwaway CA. The pki package built earlier makes that a few lines.

package server_test

import (
	"crypto/tls"
	"crypto/x509"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"
)

// newTestPKI builds a CA, a server certificate for 127.0.0.1, and a
// client certificate, all valid for the duration of the test.
func newTestPKI(t *testing.T) (ca *pki.CA, serverCert, clientCert tls.Certificate) {
	t.Helper()

	ca, err := pki.NewCA("test-root", "Test Org", time.Hour)
	if err != nil {
		t.Fatalf("create CA: %v", err)
	}
	serverCert, err = ca.IssueLeaf(pki.LeafOptions{
		Hosts:      []string{"127.0.0.1", "localhost"},
		CommonName: "test-server",
		ValidFor:   time.Hour,
	})
	if err != nil {
		t.Fatalf("issue server certificate: %v", err)
	}
	clientCert, err = ca.IssueLeaf(pki.LeafOptions{
		CommonName: "test-client",
		ValidFor:   time.Hour,
		ForClient:  true,
	})
	if err != nil {
		t.Fatalf("issue client certificate: %v", err)
	}
	return ca, serverCert, clientCert
}

func TestMutualTLS(t *testing.T) {
	ca, serverCert, clientCert := newTestPKI(t)

	pool := x509.NewCertPool()
	pool.AddCert(ca.Certificate)

	srv := httptest.NewUnstartedServer(http.HandlerFunc(whoAmI))
	srv.TLS = &tls.Config{
		Certificates: []tls.Certificate{serverCert},
		ClientCAs:    pool,
		ClientAuth:   tls.RequireAndVerifyClientCert,
		MinVersion:   tls.VersionTLS12,
	}
	srv.StartTLS()
	defer srv.Close()

	client := &http.Client{
		Transport: &http.Transport{
			TLSClientConfig: &tls.Config{
				Certificates: []tls.Certificate{clientCert},
				RootCAs:      pool,
				MinVersion:   tls.VersionTLS12,
			},
		},
	}

	resp, err := client.Get(srv.URL)
	if err != nil {
		t.Fatalf("request with client certificate: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("got status %d, want 200", resp.StatusCode)
	}
}

func TestMutualTLSRejectsAnonymousClient(t *testing.T) {
	ca, serverCert, _ := newTestPKI(t)

	pool := x509.NewCertPool()
	pool.AddCert(ca.Certificate)

	srv := httptest.NewUnstartedServer(http.HandlerFunc(whoAmI))
	srv.TLS = &tls.Config{
		Certificates: []tls.Certificate{serverCert},
		ClientCAs:    pool,
		ClientAuth:   tls.RequireAndVerifyClientCert,
		MinVersion:   tls.VersionTLS12,
	}
	srv.StartTLS()
	defer srv.Close()

	// Trusts the server but presents no identity of its own.
	client := &http.Client{
		Transport: &http.Transport{
			TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12},
		},
	}

	if _, err := client.Get(srv.URL); err == nil {
		t.Fatal("expected the handshake to fail without a client certificate")
	}
}

Note that httptest.NewUnstartedServer plus StartTLS is the pattern to use when you need to control the server's tls.Config; httptest.NewTLSServer generates its own certificate and exposes it via srv.Client(), which is convenient but gives you no say in the configuration.

Expiry logic is testable the same way, by issuing a certificate with a negative ValidFor or by passing a future CurrentTime to VerifyOptions — never by sleeping.

Inspecting certificates from the command line

A short CLI that prints everything relevant about a certificate is one of the highest-value hundred lines you can write, because it turns "the handshake failed" into a specific, actionable fact.

package main

import (
	"crypto/ecdsa"
	"crypto/ed25519"
	"crypto/rsa"
	"crypto/x509"
	"fmt"
	"os"
	"strings"
	"time"
)

func describeKey(certificate *x509.Certificate) string {
	switch key := certificate.PublicKey.(type) {
	case *rsa.PublicKey:
		return fmt.Sprintf("RSA %d bits", key.N.BitLen())
	case *ecdsa.PublicKey:
		return fmt.Sprintf("ECDSA %s", key.Curve.Params().Name)
	case ed25519.PublicKey:
		return "Ed25519"
	default:
		return fmt.Sprintf("unknown (%T)", key)
	}
}

func describe(certificate *x509.Certificate) {
	fmt.Printf("Subject:        %s\n", certificate.Subject)
	fmt.Printf("Issuer:         %s\n", certificate.Issuer)
	fmt.Printf("Serial:         %s\n", certificate.SerialNumber)
	fmt.Printf("Public key:     %s\n", describeKey(certificate))
	fmt.Printf("Signature alg:  %s\n", certificate.SignatureAlgorithm)
	fmt.Printf("Valid from:     %s\n", certificate.NotBefore.Format(time.RFC3339))
	fmt.Printf("Valid until:    %s (%d days left)\n",
		certificate.NotAfter.Format(time.RFC3339),
		int(time.Until(certificate.NotAfter).Hours()/24))
	fmt.Printf("Is CA:          %t\n", certificate.IsCA)

	if len(certificate.DNSNames) > 0 {
		fmt.Printf("DNS names:      %s\n", strings.Join(certificate.DNSNames, ", "))
	}
	for _, ip := range certificate.IPAddresses {
		fmt.Printf("IP address:     %s\n", ip)
	}
	if len(certificate.OCSPServer) > 0 {
		fmt.Printf("OCSP:           %s\n", strings.Join(certificate.OCSPServer, ", "))
	}
	if len(certificate.CRLDistributionPoints) > 0 {
		fmt.Printf("CRL:            %s\n", strings.Join(certificate.CRLDistributionPoints, ", "))
	}
}

func main() {
	if len(os.Args) < 2 {
		fmt.Fprintln(os.Stderr, "usage: certinfo <file.pem>")
		os.Exit(2)
	}
	data, err := os.ReadFile(os.Args[1])
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
	chain, err := verify.ParseChainPEM(data)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
	for i, certificate := range chain {
		fmt.Printf("--- certificate %d of %d ---\n", i+1, len(chain))
		describe(certificate)
		fmt.Println()
	}
}

Common mistakes and how to avoid them

A checklist drawn from the failure modes above.

  • InsecureSkipVerify: true left in production code. It disables hostname checking and chain validation simultaneously, reducing TLS to unauthenticated encryption — which an active attacker defeats trivially. If a self-signed peer must be reached, put its certificate in a RootCAs pool instead. The only defensible use is a monitor that must report on invalid certificates.
  • Relying on Common Name for hostnames. Removed in Go 1.15. Populate DNSNames.
  • Omitting BasicConstraintsValid: true. Without it, IsCA and MaxPathLen are not encoded, and your CA is not a CA.
  • Confusing subject and issuer keys in CreateCertificate. The public key is the subject's; the private key is the issuer's.
  • Shipping the root in the server chain. Harmless but wasteful in a public PKI: it adds bytes to every handshake and the client ignores it. Send leaf plus intermediates only.
  • Omitting intermediates. The opposite error, and a much worse one: it produces the classic "works in my browser, fails in curl" bug, because browsers cache intermediates from previous sites and other clients do not.
  • Restarting to rotate. Use GetCertificate.
  • Clearing the certificate on a failed reload. Keep serving the old one and alert.
  • Forgetting clock skew. Backdate NotBefore by a few minutes; a certificate that is not yet valid fails as hard as an expired one.
  • Ignoring AppendCertsFromPEM's return value. It reports failure with a bare false, and an empty pool silently trusts nothing.
  • Treating RequireAnyClientCert as authentication. It verifies nothing at all.
  • Pinning without a backup pin. One lost key locks out every client.

Conclusion

The through-line of everything above is that Go's TLS API is small because it is built around a handful of well-chosen extension points. crypto.Signer abstracts every key type. x509.CreateCertificate issues every certificate, self-signed or CA-signed, server or client, with only the template and the parent changing. GetCertificate is simultaneously the answer to hot reload, to SNI virtual hosting, and to ACME integration — three problems that look unrelated until you notice they are all "choose a certificate at handshake time". VerifyPeerCertificate is where mutual TLS authorization and public key pinning both live.

Learn those four, understand which tls.Config field controls which side of the connection, and the rest is assembly. The remaining difficulty in TLS is not writing the Go — it is the operational discipline of rotating keys before they expire, monitoring what you are actually serving, and never letting InsecureSkipVerify survive a code review.