The handshake behind the request
You are calling an internal API that uses a self-signed certificate. The default HTTP client refuses to connect. You need to tell Go to trust a specific certificate authority, or perhaps pin a certificate to prevent man-in-the-middle attacks. Or you are connecting to a device by IP address, and the hostname verification fails. The standard library gives you full control, but the configuration lives in a few specific structs that you need to wire together.
Go separates HTTP logic from network transport. The http.Client handles the request lifecycle. It relies on a http.Transport to manage the actual connections. The transport holds a tls.Config that dictates how the TLS handshake behaves. You build the config, attach it to the transport, and give the transport to the client.
Think of the client as a dispatcher. The transport is the delivery truck. The TLS config is the manifest that tells the truck which warehouses it is allowed to visit and what ID badges it accepts. The dispatcher doesn't care about the manifest. It just sends the request to the truck. The truck checks the manifest before it talks to the warehouse.
Minimal configuration
Here is the skeleton: create the config, wire it to the transport, use the client.
package main
import (
"crypto/tls"
"fmt"
"net/http"
)
func main() {
// Configure TLS settings. InsecureSkipVerify is for testing only.
tlsConfig := &tls.Config{
InsecureSkipVerify: true, // Disables certificate verification. Never use in production.
}
// Create a transport with the custom TLS config.
transport := &http.Transport{
TLSClientConfig: tlsConfig,
}
// Attach the transport to a client.
client := &http.Client{
Transport: transport,
}
// Make the request.
resp, err := client.Get("https://example.com")
if err != nil {
fmt.Println("Request failed:", err)
return
}
defer resp.Body.Close()
fmt.Println("Status:", resp.Status)
}
The InsecureSkipVerify field tells the client to ignore certificate errors. This allows connections to servers with self-signed or expired certificates. It also disables hostname verification. Use this only during local development. Production code must verify certificates.
The community accepts the verbose if err != nil pattern because it makes the unhappy path visible. Every network call can fail. Checking the error immediately prevents silent failures.
How the transport uses the config
When you call client.Get, the client asks the transport for a connection. The transport checks its pool of idle connections. If it finds a connection to the same host, it reuses it. If not, it creates a new TCP connection and starts the TLS handshake.
The handshake uses the TLSClientConfig from the transport. If the config is nil, the transport uses system defaults. If you provide a config, the transport uses your settings for every new connection it creates. The config applies only to new connections. Changing the config after the transport is created does not affect existing connections in the pool.
This behavior has a practical consequence. You should create the transport once, configure it, and reuse it across requests. Creating a new transport for every request disables connection pooling and forces a new TCP handshake and TLS negotiation for each call. The overhead adds up quickly.
The client is a wrapper around the transport. Configure the transport, not the client.
Trusting a custom certificate authority
Production code rarely skips verification. You usually need to trust a specific certificate authority. Here is how to load a CA file and use it.
// loadCA creates a TLS config that trusts a specific CA file.
func loadCA(caPath string) (*tls.Config, error) {
// Read the PEM-encoded certificate from disk.
caCert, err := os.ReadFile(caPath)
if err != nil {
return nil, fmt.Errorf("read CA file: %w", err)
}
// Initialize a pool and parse the certificate.
caCertPool := x509.NewCertPool()
if !caCertPool.AppendCertsFromPEM(caCert) {
return nil, fmt.Errorf("failed to parse CA certificate")
}
// Attach the pool to the config.
return &tls.Config{
RootCAs: caCertPool,
}, nil
}
The x509.NewCertPool creates an empty trust store. AppendCertsFromPEM parses the certificate data and adds it to the pool. The pool can hold multiple certificates. This is useful when your organization uses a chain of intermediate CAs.
Here is how to wire the loaded CA into a client.
func main() {
// Load the custom CA.
tlsConfig, err := loadCA("internal-ca.pem")
if err != nil {
fmt.Println("Setup failed:", err)
return
}
// Create client with custom transport.
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: tlsConfig,
},
}
resp, err := client.Get("https://internal-api.example.com")
if err != nil {
fmt.Println("Request failed:", err)
return
}
defer resp.Body.Close()
fmt.Println("Status:", resp.Status)
}
Setting RootCAs replaces the system trust store. The client will only trust certificates signed by the CAs in your pool. If you want to add a CA to the system store instead of replacing it, use x509.SystemCertPool. This function returns the OS trust store. You can append your CA to it and pass the result to RootCAs. This preserves trust for public websites while adding trust for internal services.
Functions that make network calls should accept a context.Context as the first parameter. Pass it through to cancel requests if the user navigates away or a timeout occurs. The context convention is strict: ctx is the parameter name, and it always comes first.
Common pitfalls and errors
The ServerName field causes confusion when connecting to IP addresses. When you connect to an IP, the TLS handshake has no hostname to verify against the certificate. The server might send a cert for api.example.com, but the client sees an IP. The handshake fails with x509: certificate is valid for api.example.com, not 192.168.1.50. You must set ServerName in the config to tell the client which hostname to expect.
tlsConfig := &tls.Config{
ServerName: "api.example.com", // Override the hostname check.
}
The ServerName field also controls the SNI extension sent to the server. Many servers host multiple domains on one IP and use SNI to select the correct certificate. If you omit ServerName when connecting to an IP, the server might send a default certificate that does not match your expected domain.
The compiler enforces types strictly. If you try to assign a tls.Config directly to http.Client, the compiler rejects it with cannot use config (variable of type *tls.Config) as *http.Transport value in struct field. The client expects a transport, not a config. You must wrap the config in a transport.
The runtime returns x509: certificate signed by unknown authority when the server presents a cert that isn't in the system trust store or your custom pool. This error means the certificate chain is incomplete or the root CA is missing. Check that your CA file contains the full chain, including intermediate certificates.
Trust the certificate chain. Skip verification only when you have no other choice and you understand the risk.
When to use custom TLS
Use the default http.DefaultClient when you are calling public APIs with valid certificates and standard TLS versions.
Use a custom http.Transport with TLSClientConfig when you need to trust a private certificate authority or pin a specific certificate.
Use InsecureSkipVerify only during local development against a server with a self-signed certificate, and never in production code.
Use tls.Config.MinVersion and tls.Config.MaxVersion when you must enforce a specific TLS version, such as requiring TLS 1.3 for compliance.
Use tls.Config.ServerName when you are connecting to an IP address or a hostname that does not match the URL, to ensure the handshake verifies the correct certificate.
Use x509.SystemCertPool when you want to add a custom CA to the existing system trust store instead of replacing it entirely.
Default is best. Customize with intent.