skip to content

Which tls.Config fields replace InsecureSkipVerify when a Go client must trust a private CA?

level: middleimportance: should knowfreq 44%

answer

  1. two questions: who signed it, and who is it
  2. one field is a pool, one is a string
  3. the pool does not extend the system store
  4. dial an address, demand a name
  5. empty name defaults to the dialled host

basics

~20 s

Two fields do the job: RootCAs, the pool of certificate authorities the chain must reach, and ServerName, the name the certificate must carry. Set both and verification succeeds against a private CA with the check still on.

solid answer

~50 s

`tls.Config.RootCAs` is the client's trust set. When it is nil the host's system trust store is used; when it is non-nil it **replaces** that store entirely, so a pool holding only your internal root will trust internal peers and nothing else — usually what you want for a service that only ever dials one collector, but a surprise if the same client also talks to public endpoints. `tls.Config.ServerName` is the name checked against the certificate's Subject Alternative Names, and it is also the SNI value. Leave it empty and it defaults to the host part of the address you dialled, which is why dialling an IP fails against a certificate that names a service. Setting `ServerName` to that name lets you keep connecting by IP while still verifying identity. Together they cover both reasons a handshake fails, and neither weakens the check.

code

go · 5 lines
go
// caPool holds the company root certificate, built once at start-up.
conn, err := tls.Dial("tcp", "10.0.4.17:9000", &tls.Config{
	RootCAs:    caPool,             // replaces the system trust store
	ServerName: "collector.internal", // the name the certificate must carry
})

go deeper

for a junior

Know the names of the two fields and what each supplies: RootCAs is the set of authorities you trust, ServerName is the name you insist the certificate carries.

for a middle

Explain that assigning RootCAs replaces the system trust store rather than extending it, and that ServerName defaults to the host part of the dialled address.

for a senior

Map each handshake error to the field that fixes it, and argue for a narrow per-destination trust pool instead of one merged pool used everywhere.

for a principal

Decide where the trust set comes from across a fleet: baked into images, mounted by the platform, or fetched at start-up, and who is accountable when a root rotates.

## The two inputs to verification Certificate verification in a Go client answers two questions, and each has one config field behind it. **"Do I trust the issuer?"** — answered by `tls.Config.RootCAs`, an `*x509.CertPool`. **"Is this the host I asked for?"** — answered by `tls.Config.ServerName`, a string. When a handshake fails, exactly one of those questions got a "no", and the error message tells you which. `x509: certificate signed by unknown authority` is the first. `x509: certificate is valid for collector.internal, not 10.0.4.17` is the second. Disabling verification "fixes" both by refusing to ask; setting the right field fixes the one that is actually wrong. ## RootCAs replaces, it does not extend This is the detail interviewers probe. If `RootCAs` is nil, the client uses the platform's trust store — on Unix-like systems that is the CA bundle files `crypto/x509` looks for in the usual locations, overridable process-wide with the `SSL_CERT_FILE` and `SSL_CERT_DIR` environment variables. The moment you assign a non-nil pool, that default is gone: the chain must terminate in **your** pool. For a service that dials only internal endpoints, a pool containing just the company root is the tighter and better configuration — it will refuse a certificate from any public CA, which is a real narrowing of the trust set rather than a side effect to apologise for. If one client must reach both internal and public endpoints, either start from a copy of the system pool and add the internal root to it, or — cleaner — give each destination its own `tls.Config`, so the internal-only trust set never leaks onto a public call. A pool is shared, immutable-in-practice state: build it once at start-up, hold it in the config, and let the same `*tls.Config` be reused across connections. `crypto/tls` is safe for concurrent use with a config that is not mutated after it starts being used. ## ServerName is both the checked name and SNI `ServerName` carries the identity you expect. It is compared against the certificate's Subject Alternative Names (the Common Name has not been consulted for this purpose in Go for many releases), and it is sent in the ClientHello as SNI so a server can select a certificate. If you leave it empty, it is filled in for you from the address: `tls.Dial` sets it from the host part of the address you passed, and an HTTP client sets it from the request URL's host. That default is right almost all the time — and wrong in exactly the cases that tempt people to disable verification: - You dial a **raw IP** because you resolved it yourself, went through a load balancer, or are talking to a pod address. The default `ServerName` is then the IP, and the certificate must carry that IP as a SAN — usually it does not. - You dial a **local forwarded port** such as `127.0.0.1:9443` while the certificate names the real service. - You dial through a **bastion or tunnel**, where the transport address has nothing to do with the service identity. In every one of those, setting `ServerName` to the name the certificate carries restores verification without touching where the packets go. That is the whole trick: the address decides *where you connect*, `ServerName` decides *who you demand to be there*. ## Putting them together A log shipper that keeps a long-lived connection to a collector inside the company network ends up with a config like `&tls.Config{RootCAs: caPool, ServerName: "collector.internal"}` and dials whatever address service discovery hands it. The certificate must be signed by the company root **and** must name `collector.internal`. An impostor at that address, even one holding a valid certificate from a public CA, is rejected — which is strictly stronger than what a browser does with the same connection. ## What this does not cover These two fields set the *inputs* to the standard check. They do not let you add a rule of your own — "the certificate must also carry this organisational unit", or "the public key must be this exact one". For that you keep both fields set and add a `VerifyPeerCertificate` callback, which runs after the standard verification and can reject a chain the standard rules accepted. And they say nothing about what you present to the far side; a certificate the client offers when a server demands one is a separate field. ## The operational habit When a handshake fails, read the error before changing anything: an issuer failure and a name failure look similar from a dashboard and have completely different fixes. Then change the one field that addresses it. If neither field can express what you need, the answer is a callback — never the skip flag.

  • What is trusted if RootCAs is left nil?
    The host's system trust store — the platform CA bundle, which on Unix-like systems `crypto/x509` locates in the usual filesystem locations and which the `SSL_CERT_FILE` and `SSL_CERT_DIR` environment variables can redirect. That is why a Go binary copied into an image with no CA bundle fails every outbound handshake: the effective root set is empty. Assigning a non-nil `RootCAs` replaces this default rather than adding to it.
  • The client must reach both an internal endpoint and a public API. How do you configure it?
    Use two configs. Give the internal call a `tls.Config` whose `RootCAs` holds only the company root, and let the public call use a config with `RootCAs` nil so it verifies against the system store. Merging both trust sets into one pool works but widens the internal call's trust to every public CA, which throws away the main benefit of running a private CA.
  • Does setting ServerName change which host the connection goes to?
    No. The address argument decides where the TCP connection goes; `ServerName` decides which identity the certificate must prove and which name is advertised in SNI so the server can choose a certificate. That separation is what lets you dial an IP, a tunnel or a forwarded local port and still verify the real service's name.

saying these in an interview costs you the question

  • Thinks a non-nil RootCAs is added to the system trust store
  • Believes ServerName changes which host is connected to
  • Says an empty ServerName means no name is checked
  • Reaches for the skip flag when only the name mismatches
  • Expects the certificate's Common Name to be matched