skip to content

When does a tls.Config.VerifyPeerCertificate callback run in Go, and what is it handed?

level: seniorimportance: nice to knowfreq 26%

answer

  1. it runs after, not instead of
  2. two arguments, one of them can be nil
  3. returning an error kills the handshake
  4. no chain was built, so no chain is passed
  5. resumed connections skip it

basics

~20 s

It runs after the standard certificate verification, on both clients and servers, receiving the peer's raw DER certificates and any chains that verification built. Returning an error aborts the handshake. It adds rules; it does not replace the built-in ones.

solid answer

~50 s

The field is `VerifyPeerCertificate func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error`. `crypto/tls` calls it after normal verification has already succeeded, so it is the place to add a rule the standard check cannot express — pin a public key, require a particular organisational unit, consult a revocation list. Returning a non-nil error aborts the handshake and that error is what the caller sees. The subtlety is `verifiedChains`: if verification was disabled with `InsecureSkipVerify`, no chain was built and the argument is nil, leaving your callback holding only raw DER bytes and the entire job of verifying them. So a hook does not make skipping safe unless it re-implements everything skipping removed. Note too that it does not run on a resumed connection; `VerifyConnection`, which runs afterwards and on every connection including resumptions, is the place for checks that must hold every time.

code

go · 11 lines
go
cfg := &tls.Config{
	RootCAs:    caPool,
	ServerName: "collector.internal",
	VerifyPeerCertificate: func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
		leaf := verifiedChains[0][0]
		if sha256.Sum256(leaf.RawSubjectPublicKeyInfo) != wantSPKI {
			return errors.New("collector: public key pin mismatch")
		}
		return nil
	},
}

go deeper

for a junior

Know that Go lets you attach a callback to a TLS config to add your own certificate rule, and that it tightens the standard check rather than replacing it.

for a middle

State the two arguments and what each holds, and explain why a callback returning nil cannot rescue a chain that already failed to verify.

for a senior

Explain the nil verifiedChains case and the size of the obligation it creates, and choose deliberately between this hook and VerifyConnection given session resumption.

for a principal

Weigh pinning as a policy: what it buys against a mis-issued certificate, what it costs when a key must rotate under pressure, and who is on the hook for shipping backup pins.

## The hook `tls.Config` exposes a callback with this signature: ```go VerifyPeerCertificate func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error ``` It is called on either side of a connection after the normal certificate verification the library performs. `rawCerts` holds the ASN.1 DER bytes exactly as the peer sent them, leaf first. `verifiedChains` holds the chains standard verification managed to build; each chain starts with the leaf certificate and ends at a trusted root. A non-nil return aborts the handshake, and that error propagates out of the dial or the accept. ## "After" is the word that matters Because the hook runs *after* verification, it can only make the check stricter. If the chain did not verify, the handshake has already failed and your callback never runs — which is why installing a callback that returns nil does nothing to rescue an `x509: certificate signed by unknown authority` failure. This ordering is the useful property: you keep every guarantee the standard rules give you and layer one more requirement on top. Typical additional rules: - **Public-key pinning.** Hash the leaf's `RawSubjectPublicKeyInfo` and compare against a value you shipped. Pinning the key rather than the whole certificate survives renewal with the same key pair. - **Issuer or subject constraints.** Require that the chain passes through a specific intermediate, or that the leaf's organisational unit matches the service you meant to reach. - **Freshness or revocation.** Consult a locally cached revocation list, or reject certificates whose remaining validity is below a threshold. ## The nil-chains trap The callback's second argument is nil whenever standard verification did not run. On a client that means `InsecureSkipVerify` was set. This produces the pattern people reach for when they want "custom verification": disable the built-in check, then verify by hand inside the hook. It is legitimate — the standard library documents it as the way to implement verification the library cannot express — but it is a much bigger commitment than it looks. Inside that hook you now owe: parsing every raw certificate, building a chain to your roots, enforcing validity periods, basic constraints, key usages and path lengths, and matching the expected name. Miss one and you have written an authentication bypass that looks like security code. Ninety-nine times out of a hundred the requirement can be expressed as `RootCAs` plus `ServerName` plus a small extra rule in the hook with verification left **on**, and that is the shape to reach for. Reserve the disabled-plus-reimplement form for genuinely unusual requirements, and treat it as security-critical code that gets reviewed and tested as such. A quiet corollary: a hook written for the verification-on case will dereference `verifiedChains[0][0]`. If somebody later adds `InsecureSkipVerify` to the same config, that slice becomes empty and the hook panics rather than silently weakening — an accidental but welcome tripwire. Do not rely on it, but do not defensively swallow it either. ## Resumption, and the sibling callback TLS session resumption skips sending the certificate chain again, so `VerifyPeerCertificate` is not invoked on a resumed connection. If your extra rule is a genuine authorisation decision — "this peer is allowed to talk to me" — that gap matters, because a peer that passed once keeps its session ticket. The answer is `VerifyConnection func(tls.ConnectionState) error`, the other hook on `tls.Config`. It runs after normal verification and after `VerifyPeerCertificate`, it receives the whole connection state (peer certificates, verified chains, negotiated version, `ServerName`), and it is documented to run for **all** connections, including resumptions, whatever the verification settings are. As a rule of thumb: a rule about the certificate's contents can live in `VerifyPeerCertificate`; a rule that must hold on every single connection belongs in `VerifyConnection`. ## Cost and failure behaviour The callback is on the handshake path, so it runs on every new connection and its latency is added to connection setup. Anything network-bound inside it — fetching a revocation list, calling a policy service — turns a dependency's outage into a total inability to connect, and does so under exactly the conditions where connections are being re-established. Cache aggressively, bound the work, and decide deliberately whether the closed state is fail-open or fail-closed. Also remember the error you return is the error an operator will read in a log at 3am. `errors.New("collector: public key pin mismatch")` is a better handshake failure than a bare comparison failure with no context. ## Where it sits among the config's other knobs `RootCAs` and `ServerName` configure the standard check. `VerifyPeerCertificate` extends it. `VerifyConnection` extends it in a place resumption cannot skip. `InsecureSkipVerify` removes it. Only the last of those weakens anything, and it is the only one people reach for in a hurry.

  • How does VerifyConnection differ from VerifyPeerCertificate?
    `VerifyConnection` takes a `tls.ConnectionState`, so it sees the peer certificates, the verified chains, the negotiated version and `ServerName` in one place. It runs after `VerifyPeerCertificate`, and crucially it runs on every connection including resumed ones, whatever the verification settings are. Put content-based certificate rules in `VerifyPeerCertificate`; put decisions that must hold on each connection, such as "is this peer still allowed", in `VerifyConnection`.
  • Does installing this hook make InsecureSkipVerify safe?
    Only if the hook does everything the flag removed: parse the raw certificates, build a chain to your roots, enforce validity and constraints, and match the expected name. That is a lot of security-critical code to get right. If the requirement can be stated as "trust this CA, expect this name, plus one extra rule", set `RootCAs` and `ServerName`, leave verification on, and let the hook carry only the extra rule.
  • Why pin the public key rather than the whole certificate?
    A certificate is reissued on every renewal, so pinning its bytes means the pin breaks on a routine rotation — and the pressure to "just remove the pin" arrives during an outage. Hashing the subject public key info survives renewal as long as the key pair is kept, so the pin only breaks on a deliberate key change. Ship at least one backup pin so a forced key rotation is survivable.

saying these in an interview costs you the question

  • Thinks the callback replaces the standard verification
  • Expects verifiedChains to be populated when verification is disabled
  • Believes returning nil rescues a failed chain build
  • Assumes the hook runs on resumed connections
  • Performs an unbounded network call inside the handshake path
  • Pins the whole certificate and is surprised when renewal breaks it