How to Use the crypto/tls Package in Go

Use crypto/tls to create secure connections by wrapping a net.Conn with tls.Client or tls.Server and a tls.Config.

The handshake you never see

You write a Go program that needs to talk to an external API. You hardcode the URL, send a request, and get a response. It works on your laptop. You deploy it to production and the connection drops. The network team tells you the server requires a valid certificate chain. You did not think about encryption because the HTTP library handled it for you. Now you are writing a raw TCP client and plaintext data is flying across the wire. That is where crypto/tls steps in.

What TLS actually does under the hood

TLS is the digital equivalent of a sealed diplomatic pouch. You hand your data to a courier, the courier locks it in a tamper-proof container, and only the intended recipient has the key to open it. The Go standard library splits this into two pieces. The crypto/tls package handles the cryptographic handshake, certificate validation, and encrypted stream. The net package handles the actual socket. You combine them by wrapping a raw network connection in a TLS layer. The wrapper intercepts every read and write, encrypts outgoing bytes, and decrypts incoming bytes before your application ever sees them.

Go ships with strict defaults. The standard library refuses to connect to servers with expired certificates, mismatched hostnames, or weak cipher suites. You do not need to configure a secure baseline. You only need to override defaults when you have a specific requirement. Trust the defaults until you have a measured reason to change them.

The simplest client connection

Here is the minimal client that connects to a remote server and verifies its certificate.

package main

import (
	"crypto/tls"
	"fmt"
	"net"
)

func main() {
	// Empty config uses system trust store and modern cipher defaults
	config := &tls.Config{
		ServerName: "example.com",
	}

	// Dial wraps net.Dial with TLS handshake automatically
	conn, err := tls.Dial("tcp", "example.com:443", config)
	if err != nil {
		// Verbose error handling keeps the failure path visible
		fmt.Println(err)
		return
	}
	// Defer cleanup until main exits to avoid resource leaks
	defer conn.Close()

	// conn implements net.Conn, so you can read/write normally
	fmt.Println("Connected to", conn.RemoteAddr())
}

The ServerName field triggers SNI (Server Name Indication). It tells the server which domain you are trying to reach. The server uses that hint to pick the correct certificate from its pool. If you omit ServerName, the handshake still works, but certificate validation will fail if the server presents a wildcard or multi-domain cert. Always set it when you know the target hostname.

How the wrapper intercepts your data

tls.Dial does three things behind the scenes. It opens a TCP socket to the target address. It initiates the TLS handshake using the ServerName to verify the certificate matches the domain. It returns a *tls.Conn that satisfies the net.Conn interface. Your application treats it exactly like a regular socket. The encryption happens transparently in the background.

When you call conn.Write(data), the TLS layer buffers the bytes, splits them into TLS records, encrypts them, and pushes them to the underlying TCP socket. When you call conn.Read(buf), the layer pulls encrypted records from the socket, decrypts them, verifies the MAC, and copies the plaintext into your buffer. You never touch the cipher state. You never manage the record boundaries. The interface abstraction hides the complexity.

Convention aside: Go functions that return errors always put the error as the last return value. The community accepts the if err != nil { return err } boilerplate because it makes the unhappy path impossible to ignore. Write it out. Do not swallow it.

Running a secure server

Clients are straightforward. Servers require certificates and private keys. Here is a realistic server that listens for TLS connections and echoes data back.

package main

import (
	"crypto/tls"
	"fmt"
	"log"
	"net"
)

// startServer listens on a port and handles incoming TLS connections
func startServer() {
	// Load cert and key from disk once at startup
	cert, err := tls.LoadX509KeyPair("server.crt", "server.key")
	if err != nil {
		log.Fatal(err)
	}

	// Attach the loaded certificate to the config
	config := &tls.Config{
		Certificates: []tls.Certificate{cert},
	}

	// Create a standard TCP listener first
	listener, err := net.Listen("tcp", ":8443")
	if err != nil {
		log.Fatal(err)
	}
	// Wrap the listener so every accepted connection gets TLS
	tlsListener := tls.NewListener(listener, config)
	defer tlsListener.Close()

	fmt.Println("Listening on :8443")
	for {
		// Accept blocks until a client connects and completes handshake
		conn, err := tlsListener.Accept()
		if err != nil {
			continue
		}
		// Handle each connection in its own goroutine
		go handleConnection(conn)
	}
}

// handleConnection reads from the client and writes back
func handleConnection(conn net.Conn) {
	defer conn.Close()
	buf := make([]byte, 1024)
	for {
		// Read blocks until data arrives or connection closes
		n, err := conn.Read(buf)
		if err != nil {
			return
		}
		// Echo the exact bytes back to the client
		conn.Write(buf[:n])
	}
}

The tls.NewListener wrapper is the cleanest way to run a TLS server. It intercepts the Accept() call, forces the handshake immediately, and only returns a connection once encryption is established. You avoid half-open sockets. You avoid plaintext leakage. The Certificates slice holds your public key and private key pair. Go expects PEM-encoded files. The compiler rejects this with crypto/tls: failed to parse certificate if the file format is wrong or the key does not match the cert.

Convention aside: Public names start with a capital letter. Private names start lowercase. There are no public or private keywords. The capitalization rule is the only access modifier in Go. Name your exported functions and types carefully.

Where things go wrong

The most common mistake is disabling verification. Developers add InsecureSkipVerify: true to make a local test work, then commit it to production. That flag tells Go to ignore certificate expiration, hostname mismatches, and unknown root CAs. It turns your encrypted tunnel into a plaintext pipe with extra steps. The compiler will not stop you. The runtime will not warn you. Attackers will.

If you need to trust an internal certificate authority, do not skip verification. Load the CA certificate into a x509.CertPool and assign it to config.RootCAs. The handshake will still validate the chain, but it will accept your internal root. If you forget to set RootCAs and the server uses a private CA, the connection fails with x509: certificate signed by unknown authority. That error is a feature. It means Go is doing its job.

Another trap is mixing net.Conn and *tls.Conn incorrectly. The tls.Conn type implements net.Conn, so you can pass it to functions expecting the interface. You cannot cast a net.Conn back to *tls.Conn without a type assertion. If you try to call tls.Conn methods on a raw TCP socket, the compiler rejects this with undefined: conn.Handshake or a similar method-not-found error. Stick to the interface unless you specifically need TLS state inspection.

Goroutine leaks happen when you spawn a handler for each connection but forget to close the channel or context that signals shutdown. Always pair go handleConnection(conn) with a cancellation path. The worst goroutine bug is the one that never logs.

Picking the right TLS approach

Use tls.Dial when you need a quick client connection with system defaults and a known hostname. Use tls.Client wrapping a net.Conn when you need custom dialer options, connection timeouts, or integration with an existing connection pool. Use tls.Listen when you are building a server that requires encrypted connections from the start and want the handshake to complete before Accept() returns. Use tls.Server wrapping an existing listener when you need to upgrade plaintext connections to TLS mid-stream or share a single port between HTTP and HTTPS. Use a custom VerifyConnection callback when you need to validate certificates against an internal PKI or enforce client certificate authentication without writing your own handshake logic.

Where to go next

Trust the defaults. Verify the chain. Encrypt everything.