How do you set TLS options for outbound HTTPS calls from a Go http.Client?
answer
- the client holds policy, not TLS
- one field on the transport
- do not edit the process-wide default
- clone keeps the good defaults
- http.Transport.TLSClientConfig, set once
basics
~20 sGive the client its own http.Transport and set that transport's TLSClientConfig field to a *tls.Config. http.Client has no TLS settings of its own, and editing http.DefaultTransport changes TLS behaviour for every caller in the process.
solid answer
~40 sTLS on the dialing side lives on the transport: `http.Transport.TLSClientConfig` is the `*tls.Config` used for every connection that transport dials. The usual recipe is `tr := http.DefaultTransport.(*http.Transport).Clone()`, then `tr.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS13}`, then `&http.Client{Transport: tr}` — built once at startup and reused, because a fresh transport per call throws away every pooled connection. Cloning rather than writing `&http.Transport{TLSClientConfig: cfg}` matters: a hand-built transport with a custom TLS config no longer gets HTTP/2 automatically unless `ForceAttemptHTTP2` is set, and the clone already carries it. For protocols that are not HTTP, `tls.Dial("tcp", addr, cfg)` returns a `*tls.Conn` directly, and `tls.Dialer` offers a context-aware `DialContext`. Treat the config as immutable once connections exist; clone it instead of editing it.
code
go · 3 linestr := http.DefaultTransport.(*http.Transport).Clone()
tr.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS13}
client := &http.Client{Transport: tr} // build once at startup, reusego deeper
Remember where the setting lives: http.Client has no TLS fields, so you set TLSClientConfig on an http.Transport and give the client that transport.
Explain why the clone idiom beats a hand-built transport, why the default transport is shared process-wide, and that pooled connections do not pick up a config change.
Talk about consistency: one constructor that every caller uses, so an outbound TLS policy is one function to review rather than a grep across the codebase, and reach for tls.Dial when the protocol is not HTTP.
Own the fact that outbound TLS is the half that gets forgotten. Decide how the platform ships a client constructor, and how you detect services that build their own.
## The client has no TLS knobs `http.Client` is a policy object: it holds a `Transport`, a timeout, a redirect policy and a cookie jar. It has no TLS fields whatsoever. Everything about the TLS handshake on the outbound side belongs to `http.Transport`, whose `TLSClientConfig *tls.Config` is consulted whenever the transport dials a new HTTPS connection. ```go tr := http.DefaultTransport.(*http.Transport).Clone() tr.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS13} client := &http.Client{Transport: tr} // build once, reuse everywhere ``` That is the whole mechanism. The interesting parts are the three mistakes around it. ## Mistake one: editing the default transport `http.DefaultTransport` is a single package-level value shared by `http.DefaultClient` and by every library in the process that did not bring its own. Assigning to its `TLSClientConfig` changes TLS for code you have never read — a metrics reporter, a health checker, some SDK. It is also a data race if anything is already using it. Clone it and edit the clone; the clone starts from the same sensible dialer and pool settings, which is why it is preferable to constructing a transport from scratch. ## Mistake two: a hand-built transport quietly loses HTTP/2 `http.Transport` upgrades itself to HTTP/2 automatically only when you have not customised the dialing or TLS path. Once you set `TLSClientConfig` (or a custom `DialContext`), the automatic upgrade is off unless `ForceAttemptHTTP2` is true. `http.DefaultTransport` already has that field set, so a clone keeps HTTP/2; `&http.Transport{TLSClientConfig: cfg}` does not, and the symptom is a mysterious protocol downgrade weeks later with no error anywhere. This is the strongest single argument for the clone idiom. ## Mistake three: a client per request A transport is a connection pool. Constructing one per request means a fresh TCP connection and a fresh TLS handshake every time, with the old ones lingering until they idle out. Build the client once at startup, hand it to whatever needs it, and let the pool do its job. ## Changing the config later does not re-handshake If you mutate `TLSClientConfig` after the transport has already dialed, connections already sitting in the pool are unaffected — they were negotiated under the old settings and will be reused as they are. Only newly dialed connections see the change. Worse, editing a `*tls.Config` that live connections are reading is a data race; `tls.Config` has a `Clone` method precisely so you can build a modified copy instead. In practice, decide the TLS config at startup and treat it as frozen. ## Dialing TLS without HTTP Not every outbound TLS connection is an HTTP request. For a line protocol, a health probe, or anything speaking its own bytes over TLS, `crypto/tls` dials directly: ```go conn, err := tls.Dial("tcp", "payments.internal:8443", &tls.Config{MinVersion: tls.VersionTLS12}) if err != nil { return err } defer conn.Close() ``` `tls.Dial` returns a `*tls.Conn`, which is a `net.Conn` you can read and write normally, plus a `ConnectionState()` method reporting what the handshake actually agreed. When you need cancellation, `tls.Dialer{NetDialer: &net.Dialer{}, Config: cfg}` provides `DialContext`, which is the same thing with a `context.Context`. One convenience worth knowing: if the config's `ServerName` is empty, `tls.Dial` fills it in from the address you dialed, which is what makes the plain three-argument call work at all. ## Applying a policy consistently Because the setting lives on a transport rather than on the language, an outbound TLS policy is only as good as the number of places that construct clients. The workable pattern in a codebase of any size is one constructor — `func newHTTPClient() *http.Client` — that clones the default transport, applies the config, and is the only sanctioned way to get a client. Then the policy is one function to review and one function to change, and a code search for `&http.Client{` finds the exceptions.
- Why clone http.DefaultTransport instead of writing &http.Transport{TLSClientConfig: cfg}?Two reasons. The clone inherits the standard dialer and pool settings instead of leaving you to rediscover them. More subtly, a transport with a custom TLS config no longer attempts HTTP/2 automatically unless `ForceAttemptHTTP2` is set — and the default transport already sets it, so the clone keeps HTTP/2 while a hand-built transport silently drops to HTTP/1.1.
- You changed TLSClientConfig after the client had made requests, and the next request behaved as before. Why?The transport reused a connection already in its pool. That connection was negotiated under the old config and is not renegotiated; only a newly dialed connection consults the new settings. Mutating a `tls.Config` that live connections are reading is also a data race — build a modified copy with its `Clone` method, or better, fix the config at startup.
- What does tls.Dial give you that going through http.Client does not?A raw `*tls.Conn` for protocols that are not HTTP, and direct access to `ConnectionState()` so you can see the negotiated version and cipher suite for that one connection. It is the natural tool for a probe or a line protocol. `tls.Dialer.DialContext` is the same thing when you need the dial to honour a context.
saying these in an interview costs you the question
- Sets TLSClientConfig on http.DefaultTransport, changing the whole process
- Creates a new http.Client and Transport for every request
- Believes http.Client itself has TLS configuration fields
- Mutates a tls.Config while connections are using it
- Assumes a hand-built Transport still negotiates HTTP/2