Blog / Go

  • go
  • crypto-tls
  • tls
  • verifyconnection
  • certificate-pinning
  • x509

Go's VerifyConnection: Certificate Checks Without a Custom RootCAs

Somewhere in most large Go codebases there's a TLS client with InsecureSkipVerify: true and a comment explaining that it's fine, actually, because there's a manual verification step further down. Usually the reason it got there is that someone wanted one extra check beyond "is this certificate valid": pin it to a known public key, require a specific SAN, reject anything that isn't the CA they expect even though the system trust store would happily accept it. The normal verification path didn't seem to have a hook for that, so out went the whole thing, and in came a hand-rolled chain walk that has to reimplement everything crypto/tls was already doing for free.

There is a hook for this. It has been in the standard library since Go 1.15 and it is underused: tls.Config.VerifyConnection.

Two callbacks, and one is easy to reach for by mistake

crypto/tls actually gives you two extension points, and they are not interchangeable.

VerifyPeerCertificate takes the raw certificate bytes and, if normal verification already ran, the chains it built. It runs after normal verification, but it doesn't see much else: no negotiated protocol, no TLS version, no ConnectionState at all. If normal verification is disabled, the verifiedChains argument is always nil, so you're back to parsing raw bytes and building trust yourself.

VerifyConnection takes the whole tls.ConnectionState: PeerCertificates, VerifiedChains, ServerName, NegotiatedProtocol, the cipher suite, whether the connection resumed a session. The documentation is specific about when it fires: "This callback will run for all connections, including resumptions, regardless of InsecureSkipVerify or ClientAuth settings." That last clause matters more than it looks. Set VerifyConnection and it becomes the one place your extra logic is guaranteed to run, whatever else is configured on that tls.Config.

The ordering is: normal certificate verification, then VerifyPeerCertificate, then VerifyConnection. If normal verification fails, the handshake aborts before either callback sees anything, unless you've turned normal verification off.

Keeping RootCAs nil

The point of VerifyConnection is that it composes with the verification Go already does instead of replacing it. Leave RootCAs unset, let the system trust store do its job, and add your own invariant on top:

package main

import (
	"crypto/sha256"
	"crypto/tls"
	"errors"
	"net/http"
)

// pinnedSPKI is the SHA-256 hash of the SubjectPublicKeyInfo of the
// key we expect to see, computed offline with:
//   openssl x509 -in cert.pem -pubkey -noout |
//     openssl pkey -pubin -outform der |
//     openssl dgst -sha256
var pinnedSPKI = [32]byte{
	0x1a, 0x2b, 0x3c, 0x4d, 0x5e, 0x6f, 0x70, 0x81,
	0x92, 0xa3, 0xb4, 0xc5, 0xd6, 0xe7, 0xf8, 0x09,
	0x1a, 0x2b, 0x3c, 0x4d, 0x5e, 0x6f, 0x70, 0x81,
	0x92, 0xa3, 0xb4, 0xc5, 0xd6, 0xe7, 0xf8, 0x09,
}

func pinnedClient() *http.Client {
	cfg := &tls.Config{
		// RootCAs stays nil: normal verification still runs
		// against the system trust store, hostname included.
		VerifyConnection: func(cs tls.ConnectionState) error {
			if len(cs.VerifiedChains) == 0 {
				return errors.New("tls: no verified chain to pin against")
			}
			leaf := cs.VerifiedChains[0][0]
			got := sha256.Sum256(leaf.RawSubjectPublicKeyInfo)
			if got != pinnedSPKI {
				return errors.New("tls: certificate does not match pinned key")
			}
			return nil
		},
	}
	return &http.Client{
		Transport: &http.Transport{TLSClientConfig: cfg},
	}
}

Comparing two [32]byte values with != is fine here; there's no secret involved, just a public key hash, so there's nothing to time an attack against and no reason to reach for crypto/subtle.

Compare that to the version that gets built when someone starts from InsecureSkipVerify: true instead:

cfg := &tls.Config{
	InsecureSkipVerify: true,
	VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
		cert, err := x509.ParseCertificate(rawCerts[0])
		if err != nil {
			return err
		}
		// now reimplement hostname matching, expiry checks,
		// key usage checks, and chain building by hand
		return nil
	},
}

Nothing in that callback checks the hostname, the certificate's validity window, or its key usage unless you write it. InsecureSkipVerify doesn't turn off "one specific check", it turns off verification entirely, and every property you still want is now your problem. VerifyConnection lets you keep the whole standard verification pass and bolt on exactly the one extra rule you actually need.

Pinning without freezing the trust store

Pinning the leaf certificate's key, as above, is the strictest option but it breaks the moment the server rotates its certificate, which for anything on a short-lived Let's Encrypt-style cycle is often. Pinning the SPKI hash of the issuing intermediate or root is more forgiving: the leaf can rotate freely as long as it's still signed by the same CA. Walk cs.VerifiedChains[0] instead of just index 0 to reach it:

chain := cs.VerifiedChains[0]
issuer := chain[len(chain)-1] // root of this particular verified chain
got := sha256.Sum256(issuer.RawSubjectPublicKeyInfo)

Either way, this is a narrower, more explicit tool than swapping in a custom RootCAs pool containing only the one CA you trust. A custom pool answers "was this signed by us", full stop; it can't express "signed by us, and also this specific business rule" without you writing the same kind of manual check anyway, and it opts you out of the OS or Go's bundled root updates for that connection. Leaving RootCAs alone and adding VerifyConnection keeps the parts of verification you don't actually want to own.

The InsecureSkipVerify case, if you're already there

If a codebase already has InsecureSkipVerify: true somewhere, VerifyConnection is worth knowing about for a different reason: it still runs. The "regardless of InsecureSkipVerify" clause in the docs means this callback is the one hook that fires whether or not normal verification happened. The catch is that cs.VerifiedChains will be empty in that case, since there's no verified chain to hand you. You'd fall back to cs.PeerCertificates and do the trust decision entirely yourself. That's a legitimate thing to do deliberately (say, trusting only a pinned key with no CA involved at all), but it's a different design decision from "add one more check on top of normal verification", and it's worth being clear about which one a given piece of code is actually doing.

On the server side

The same hook works for validating client certificates. Combine ClientAuth: tls.RequireAndVerifyClientCert, so the standard chain check still runs, with VerifyConnection to add a business rule on top of "signed by our CA":

VerifyConnection: func(cs tls.ConnectionState) error {
	if len(cs.PeerCertificates) == 0 {
		return errors.New("tls: no client certificate presented")
	}
	for _, u := range cs.PeerCertificates[0].URIs {
		if u.String() == "spiffe://example.internal/billing-service" {
			return nil
		}
	}
	return errors.New("tls: client certificate missing required URI SAN")
},

One thing worth knowing before you rely on this for debugging: the error string you return doesn't reach the other side of the connection. A failed VerifyConnection aborts the handshake locally and the peer just gets a generic TLS alert, not your custom message. Log it where the check ran, because that's the only place it will ever appear.