The checkpoint problem
You are building a service that needs to talk to an internal payment gateway. The gateway uses a certificate signed by your company's private CA, not a public one like Let's Encrypt. The standard http.Get call fails immediately because Go's default trust store does not recognize your internal root. You also need to present your own client certificate so the gateway knows which microservice is making the request. The default HTTP client will not do this for you. You need to tell the TLS layer exactly which certificates to trust, which ones to present, and how strictly to verify the other side.
How tls.Config works
The crypto/tls package handles the cryptographic handshake between two endpoints. Think of the handshake as a security checkpoint. The client and server exchange credentials, agree on encryption algorithms, and verify identities before any application data flows. The tls.Config struct is the instruction manual for that checkpoint. It tells Go which certificates to trust, which ones to present, which protocol versions to allow, and how strictly to verify the other side.
Every field in tls.Config has a sensible default. The defaults are designed for public internet traffic: trust the system CA pool, allow modern TLS versions, and negotiate cipher suites automatically. When you step away from public infrastructure, you override those defaults. You load a custom certificate pool, attach a client key pair, or force the server to demand a client certificate. The struct is verbose by design. The Go community accepts the boilerplate because it makes the security boundaries explicit. You see exactly what is allowed and what is blocked.
Client configuration
Here is the simplest way to configure a client that trusts a custom CA and presents a client certificate for mutual authentication.
package main
import (
"crypto/tls"
"crypto/x509"
"fmt"
"net/http"
"os"
"time"
)
func main() {
// Read the CA file that signed the server certificate
caPEM, err := os.ReadFile("internal-ca.pem")
if err != nil {
panic(err)
}
// Create an empty trust pool and inject the CA
caPool := x509.NewCertPool()
caPool.AppendCertsFromPEM(caPEM)
// Load the client identity for mTLS
clientCert, err := tls.LoadX509KeyPair("client.pem", "client-key.pem")
if err != nil {
panic(err)
}
// Build the TLS rulebook
tlsConfig := &tls.Config{
RootCAs: caPool,
Certificates: []tls.Certificate{clientCert},
MinVersion: tls.VersionTLS12,
ServerName: "api.internal.example.com",
}
// Attach the config to an HTTP transport
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: tlsConfig,
},
Timeout: 10 * time.Second,
}
resp, err := client.Get("https://api.internal.example.com/health")
if err != nil {
fmt.Println("Request failed:", err)
return
}
defer resp.Body.Close()
fmt.Println("Status:", resp.Status)
}
The RootCAs field replaces the system trust store. The client will only accept server certificates that chain back to the CA you loaded. The Certificates field tells the client to send its own identity during the handshake. The ServerName field enables SNI (Server Name Indication) and tells the verifier which hostname to check against the certificate's Subject Alternative Names. Omitting ServerName when connecting to a public host often triggers a verification failure because the client cannot match the certificate to the requested address.
What happens during the handshake
When the client initiates a connection, it sends a ClientHello message. This message lists the TLS versions it supports, the cipher suites it prefers, and the ServerName if provided. The server replies with a ServerHello, picking the highest mutually supported version and cipher suite. The server then sends its certificate chain.
The client verifies the chain against the RootCAs pool. If verification passes, the client generates a pre-master secret, encrypts it with the server's public key, and sends it over. Both sides now derive the same symmetric session keys. If ClientAuth is set on the server side, the server requests a client certificate. The client sends its certificate and a signature proving it holds the private key. The server verifies the client certificate against its ClientCAs pool. Once both sides are satisfied, the encrypted tunnel opens and application data flows.
The tls.Config struct controls every step of this exchange. Changing MinVersion drops older protocol support. Changing CipherSuites restricts the cryptographic algorithms. Changing ClientAuth flips the checkpoint from one-way to two-way. The handshake is fast, but it happens on every new connection. Connection pooling in http.Transport reuses established TLS sessions to avoid repeating the full handshake for subsequent requests.
Server configuration
Servers need a different set of rules. The server must present its own certificate, decide whether to demand a client certificate, and choose which CAs are allowed to sign those client certificates.
package main
import (
"crypto/tls"
"crypto/x509"
"fmt"
"net/http"
"os"
)
func main() {
// Load the CA that signed client certificates
clientCAPEM, err := os.ReadFile("client-ca.pem")
if err != nil {
panic(err)
}
clientCAPool := x509.NewCertPool()
clientCAPool.AppendCertsFromPEM(clientCAPEM)
// Load the server's own identity
serverCert, err := tls.LoadX509KeyPair("server.pem", "server-key.pem")
if err != nil {
panic(err)
}
// Configure the server checkpoint
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{serverCert},
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: clientCAPool,
MinVersion: tls.VersionTLS12,
}
// Wire the config into an HTTP server
server := &http.Server{
Addr: ":8443",
TLSConfig: tlsConfig,
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "mTLS verified")
}),
}
fmt.Println("Listening on :8443")
if err := server.ListenAndServeTLS("", ""); err != nil {
fmt.Println("Server stopped:", err)
}
}
The ClientAuth field controls how strictly the server treats client certificates. tls.RequireAndVerifyClientCert forces the client to present a certificate and verifies it against the ClientCAs pool. If the client fails to present a valid certificate, the server terminates the connection before any HTTP request reaches your handler. The ListenAndServeTLS("", "") call reads the certificate and key from the tls.Config instead of file paths, which keeps configuration centralized.
Common pitfalls and runtime errors
Custom TLS configurations fail in predictable ways. The most common mistake is setting InsecureSkipVerify: true to bypass a certificate error. This disables hostname verification and chain validation entirely. The compiler will not stop you, but the runtime will happily send credentials over an unverified tunnel. You get a working connection and zero security. Never use that flag in production. Fix the root cause instead.
Missing ServerName on the client side triggers verification failures when the server certificate contains multiple SANs. The runtime returns x509: certificate is valid for api.example.com, not api.internal.example.com. The client does not know which name to check, so it rejects the certificate. Always set ServerName to the exact hostname you are connecting to.
Loading certificates from disk fails when the PEM format is malformed or the private key is encrypted. The runtime panics with tls: failed to find any PEM data in certificate input or x509: password required for encrypted private key. The tls.LoadX509KeyPair function expects unencrypted PEM blocks. If your key is encrypted, decrypt it before passing it to the TLS layer, or use a dedicated secrets manager.
Deprecated cipher suites cause silent negotiation failures. Go removes support for weak algorithms in every major release. If you hardcode tls.TLS_RSA_WITH_AES_128_CBC_SHA, the client and server may fail to agree on a cipher suite. The runtime logs tls: no cipher suite supported by both client and server. Rely on the default cipher suite list unless you have a strict compliance requirement. The defaults are audited and updated automatically.
The if err != nil { return err } pattern appears constantly in TLS setup code. It looks verbose, but it forces you to handle certificate loading failures explicitly. The community accepts the repetition because it prevents silent fallbacks to insecure defaults. Trust the pattern. Write the check. Fail fast.
When to reach for custom TLS
Use the default http.Client when you are calling public APIs that use certificates from widely trusted CAs. Use a custom tls.Config with RootCAs when you need to trust an internal or private CA that is not in the system store. Use a custom tls.Config with Certificates when the server requires mutual TLS authentication. Use tls.Dial directly when you need raw TCP control over the TLS handshake, such as custom dial timeouts or non-HTTP protocols. Use http.DefaultTransport with a cloned TLSClientConfig when you want to reuse connection pooling while swapping certificate pools. Use plain sequential code when you do not need concurrency: the simplest thing that works is usually the right thing.