How to Implement TLS for TCP Connections in Go
You built a chat server. It works. You connect, you send messages, they arrive. Then you realize the data travels as plain text across the network. Anyone on the same Wi-Fi can read your messages. You need encryption. You need TLS. Go makes this straightforward, but there are traps if you treat TLS like a magic wrapper. The standard library gives you precise control, and using that control correctly is the difference between a secure service and a vulnerability.
The interface that makes wrapping possible
TLS sits on top of TCP. TCP guarantees delivery; TLS guarantees secrecy and integrity. Think of TCP as a postal service that ensures the letter arrives. TLS is the locked steel box you put the letter in, along with a way to prove the box hasn't been tampered with.
In Go, you don't replace the TCP connection. You wrap it. The key is the net.Conn interface. This interface defines the basic operations for a network connection: Read, Write, Close, LocalAddr, RemoteAddr, and SetDeadline. Both net.TCPConn and tls.Conn implement net.Conn. This means once you have a TLS connection, you can pass it to any function expecting a connection. The rest of your code doesn't need to change. You can wrap a TLS connection with a bufio.Reader, pass it to an HTTP server, or use it in a custom protocol. The type system handles the abstraction.
Convention dictates that you reuse tls.Config objects. Creating a config is cheap, but loading certificates and parsing CA pools takes work. Build the config once, then pass the same pointer to every connection. The community also expects if err != nil checks after every TLS operation. The boilerplate is verbose by design. It forces you to see where the handshake or verification might fail.
The simplest client connection
Here's the simplest way to connect to a server with TLS.
package main
import (
"crypto/tls"
"fmt"
)
func main() {
// tls.Dial performs the TCP connection and TLS handshake together.
// It returns a net.Conn, which supports standard Read and Write calls.
conn, err := tls.Dial("tcp", "example.com:443", &tls.Config{
// ServerName tells the client which hostname to expect in the certificate.
// This prevents man-in-the-middle attacks using valid certs for other domains.
ServerName: "example.com",
})
if err != nil {
fmt.Println("connection failed:", err)
return
}
defer conn.Close()
// The handshake is complete.
// conn is now a secure, encrypted pipe ready for data.
fmt.Println("connected securely")
}
tls.Dial is a convenience function. It creates the TCP connection, starts the handshake, and returns only when the connection is secure. If the handshake fails, you get an error. The returned connection implements net.Conn, so you can use it immediately.
What happens during the handshake
The handshake is a negotiation. The client and server exchange messages to agree on a protocol version, a cipher suite, and cryptographic keys. The server sends its certificate. The client verifies the certificate against a list of trusted authorities. If verification passes, both sides derive session keys and switch to encrypted communication.
The tls.Config struct controls this process. On the client, ServerName is critical. It enables SNI (Server Name Indication), which tells the server which domain you want. It also tells the client which hostname to check in the certificate. If you omit ServerName, the client can't verify the certificate matches the host. The handshake might succeed with the server, but verification fails. You get x509: certificate is valid for other-domain.com, not example.com. If the certificate chain is incomplete, the error is x509: certificate signed by unknown authority.
TLS is not a switch. It's a configuration. Get the config right, or the connection fails.
Running a TLS server
A server needs to prove its identity. You load a certificate and private key, then wrap the listener.
package main
import (
"crypto/tls"
"fmt"
)
func main() {
// Load the certificate and private key from PEM files.
// The key must be unencrypted for this function to work.
cert, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
fmt.Println("cert load failed:", err)
return
}
// Create a config with the certificate.
// Always set MinVersion to avoid legacy protocols like TLS 1.0.
config := &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
}
}
// Create the listener using the config.
// tls.Listen returns a net.Listener that performs handshakes automatically.
listener, err := tls.Listen("tcp", ":8443", config)
if err != nil {
fmt.Println("listen failed:", err)
return
}
defer listener.Close()
// Accept a connection.
// The returned conn is a net.Conn, but it's already encrypted.
conn, err := listener.Accept()
if err != nil {
fmt.Println("accept failed:", err)
return
}
defer conn.Close()
// You can type-assert to *tls.Conn if you need TLS-specific info.
tlsConn, ok := conn.(*tls.Conn)
if ok {
// ConnectionState gives details like the protocol version and cipher suite.
state := tlsConn.ConnectionState()
fmt.Printf("connected with TLS %x\n", state.Version)
}
tls.Listen creates a listener that handles TLS handshakes automatically. Every call to Accept returns a connection that has already completed the handshake. The connection is ready to use. If you need details about the TLS session, type-assert the connection to *tls.Conn and call ConnectionState.
The listener handles the handshake. Your code just reads and writes. If you need TLS details, assert the type.
The context trap
tls.Dial has no context parameter. It blocks until the handshake finishes or fails. If you need to cancel a connection attempt, tls.Dial won't help. You have to split the work. Use net.Dialer with a context to create the TCP connection, then wrap it with tls.Client.
Here's how to add cancellation support to a TLS connection.
import (
"context"
"crypto/tls"
"net"
)
func dialTLS(ctx context.Context, addr string) (net.Conn, error) {
// Create a dialer that respects the context.
// The dialer handles TCP connection timeouts and cancellation.
dialer := &net.Dialer{}
// Dial the TCP connection with context support.
// This call returns immediately if the context is cancelled.
conn, err := dialer.DialContext(ctx, "tcp", addr)
if err != nil {
return nil, err
}
// Wrap the TCP connection with TLS.
// tls.Client starts the handshake in the background.
tlsConn := tls.Client(conn, &tls.Config{
ServerName: "example.com",
})
// Perform the handshake synchronously.
// This blocks until the handshake completes or fails.
if err := tlsConn.HandshakeContext(ctx); err != nil {
conn.Close()
return nil, err
}
return tlsConn, nil
}
tls.Client wraps an existing connection. It doesn't start the handshake immediately. You call Handshake or HandshakeContext to trigger it. HandshakeContext respects cancellation. If the context expires, the handshake stops and returns an error. This pattern is essential for services that need timeouts or cancellation.
Context is plumbing. Run it through every long-lived call site.
Pitfalls and errors
Modifying a tls.Config while connections are using it causes a panic. The runtime checks this and panics with tls: config is being modified concurrently. Create the config once, then treat it as read-only. If you need dynamic certificates, use the GetCertificate callback in the config. This callback runs per-connection and allows you to select certificates based on the client request.
Using InsecureSkipVerify: true disables certificate checking. This is a security hole. Only use it for testing with a custom VerifyConnection or VerifyPeerCertificate callback. A callback lets you implement custom verification logic, such as trusting a private CA or checking a specific field in the certificate.
Verification is the point of TLS. Skip it, and you're just adding latency.
When to use each approach
Use tls.Dial when you are writing a client and want the simplest code to connect and handshake in one call.
Use tls.Listen when you are writing a server and want the listener to handle handshakes automatically for every accepted connection.
Use tls.Client or tls.Server when you already have a net.Conn and need to upgrade it to TLS, such as when implementing a custom protocol or handling a connection pool.
Use tls.Config with RootCAs when you need to trust a private certificate authority instead of the system store.
Use tls.Config with ClientAuth set to tls.RequireAndVerifyClientCert when the server must authenticate the client with a certificate.
Pick the wrapper that matches your lifecycle. Dial for clients, Listen for servers, Client/Server for manual control.