Security in Go Networking: TLS, Encryption, and Best Practices
"Sending data over plain TCP is like shouting across a crowded room — anyone standing between you and the listener can hear every word, and nothing stops them from shouting back pretending to be the person you meant to talk to. TLS is the sealed, tamper-evident envelope that turns that shout into a private conversation with a verified recipient."
What TLS Actually Buys You
Every TCP connection you've built so far has sent bytes in the clear. TLS (Transport Layer Security) wraps that connection with three guarantees:
- Confidentiality: Data is encrypted, so an eavesdropper on the network sees ciphertext, not your bytes.
- Integrity: Any tampering with the data in transit is detectable.
- Authentication: The client can verify the server is who it claims to be (and, with mutual TLS, the server can verify the client too), using certificates signed by a trusted authority.
Under the hood, the TLS handshake uses asymmetric cryptography (public/private key pairs) to safely agree on a shared symmetric key, then switches to fast symmetric encryption for the actual data — asymmetric crypto is too slow to encrypt an entire stream, but it's exactly what's needed to establish trust and exchange a key in the first place.
A certificate proves who signed a public key; a fresh ephemeral key exchange on top of it is what keeps yesterday's captured traffic safe even if tomorrow's private key leaks.
Certificates in Brief
A certificate binds a public key to an identity (like a domain name) and is signed by a Certificate Authority (CA) that both parties already trust. When a client connects, it checks that the server's certificate is signed by a CA in its trust store and that the certificate's name matches the host it dialed — the same reason browsers warn about self-signed certificates: there's no trusted third party vouching for them.
For local development, a self-signed certificate is enough to exercise TLS code, as long as your client is explicitly configured to trust it (never with a blanket "skip verification").
Go Implementation: A TLS Server
crypto/tls builds directly on the net package you already know — tls.Listen behaves exactly like net.Listen, just wrapping every accepted connection in a TLS handshake first:
package main
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"os"
"os/signal"
)
func main() {
cert, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
panic(err)
}
config := &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
}
ln, err := tls.Listen("tcp", ":9500", config)
if err != nil {
panic(err)
}
fmt.Println("TLS server listening on :9500")
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
go func() {
<-ctx.Done()
fmt.Println("shutting down, closing listener")
ln.Close()
}()
for {
conn, err := ln.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
return // graceful shutdown, not a real failure
}
fmt.Println("accept error:", err)
continue
}
go func(c net.Conn) {
defer c.Close()
_, err := fmt.Fprintln(c, "hello over an encrypted channel")
if err != nil {
fmt.Println("write to client failed:", err)
}
}(conn)
}
}
if err != nil { continue } on every Accept failure. If the listener enters a bad state — closed by another goroutine, out of file descriptors — Accept can fail on every call, and continue loops straight back into another failing call with no delay, pinning a CPU core doing nothing useful. The fix distinguishes a deliberate shutdown (net.ErrClosed, checked with errors.Is), where returning is correct, from a transient error worth logging and retrying — ideally with a short backoff on repeated failures.fmt.Fprintln(c, ...) returns an error like any other write, and the original example discarded it. If the client disconnected mid-write, the goroutine would exit via defer c.Close() with no trace anything went wrong — the same silent-failure risk as ignoring Write on a plain net.Conn. Checking and logging the error turns a mystery ("why did that client see nothing?") into a line in your logs.For an HTTP server, http.ListenAndServeTLS(addr, certFile, keyFile, handler) does the same thing at the HTTP layer, and is the standard way to serve HTTPS with the net/http server you built in the HTTP chapter.
For local testing, you can generate a self-signed certificate and key with the OpenSSL CLI:
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout server.key -out server.crt -days 365 -subj "/CN=localhost"
Go Implementation: A TLS Client
package main
import (
"crypto/tls"
"crypto/x509"
"fmt"
"os"
"time"
)
func main() {
caCert, err := os.ReadFile("server.crt")
if err != nil {
panic(err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caCert) {
panic("no valid certificates found in server.crt")
}
config := &tls.Config{
RootCAs: pool,
MinVersion: tls.VersionTLS12,
}
conn, err := tls.Dial("tcp", "localhost:9500", config)
if err != nil {
panic(err)
}
defer conn.Close()
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
buf := make([]byte, 256)
n, err := conn.Read(buf)
if err != nil {
panic(fmt.Errorf("read from server failed: %w", err))
}
fmt.Println(string(buf[:n]))
}
pool.AppendCertsFromPEM returns a bool, not an error — easy to ignore entirely, as the original example did. If server.crt is missing, malformed, or empty, the call silently does nothing, leaving pool empty; the client then fails much later with a confusing "certificate signed by unknown authority" error instead of a clear message about the actual cause: a bad or missing CA file.RootCAs tells the client exactly which CA certificates to trust — here, the self-signed certificate itself, since it's acting as its own CA for this local example. In production, tls.Config{} with no RootCAs set falls back to the operating system's trust store, which is what you want when connecting to a server with a certificate from a public CA.
tls.Config{InsecureSkipVerify: true} disables certificate verification entirely, which means the client can no longer tell a real server from an attacker impersonating it — defeating the authentication guarantee TLS exists to provide. It has legitimate uses in isolated test harnesses, but it should never reach a production build.Mutual TLS (mTLS)
Regular TLS authenticates the server to the client. Mutual TLS goes further and authenticates the client to the server too, using a client certificate presented during the same handshake — useful for service-to-service communication where both ends need strong identity, tying directly back into the authentication concepts from the previous chapter.
config := &tls.Config{
Certificates: []tls.Certificate{serverCert},
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: clientCAPool, // pool of CAs allowed to sign client certs
MinVersion: tls.VersionTLS12,
}
With ClientAuth: tls.RequireAndVerifyClientCert, the handshake fails outright unless the client presents a certificate signed by a CA in ClientCAs — authentication is enforced at the transport layer, before a single byte of application data is exchanged.
RootCAs tells a client which CAs to trust when verifying a server's certificate; ClientCAs tells a server which CAs to trust when verifying a client's certificate during mTLS. They serve mirror-image roles, and pointing both at the same pool without thinking through who is verifying whom is a common source of "why does my mTLS handshake fail" confusion — especially when client and server certificates happen to share an internal CA, making the mix-up easy to miss until a genuinely different CA is introduced.Best Practices
- Pin a minimum version: Set
MinVersion: tls.VersionTLS12(orTLS13) explicitly; don't rely on defaults that may allow older, weaker protocol versions. - Let Go choose cipher suites: Go's default cipher suite selection for TLS 1.2+ is already conservative and well-maintained; only override
CipherSuitesif you have a specific compliance requirement. - Rotate certificates before they expire: Automate renewal (e.g., with ACME/Let's Encrypt tooling) rather than tracking expiry dates manually.
- Never log private keys or full certificates with keys attached.
- Prefer TLS 1.3 where compatible: It removes several legacy cryptographic options that were sources of past vulnerabilities and completes the handshake in fewer round trips.
Try It Yourself: Timeouts, Shutdown, and Verification Failures
- Force a verification failure. Point the client at a different self-signed certificate than the one the server presents, and identify which of TLS's three guarantees from the top of this chapter that failure protects.
- Add a handshake timeout.
tls.Dialblocks until the handshake completes or fails; use(&tls.Dialer{Config: config}).DialContext(ctx, "tcp", addr)with acontext.WithTimeoutinstead, and confirm an unreachable address fails fast rather than hanging. - Test the accept-loop fix. Start the server, send it an interrupt signal while a client is mid-connection, and confirm the listener closes cleanly via
net.ErrClosedinstead of needing a hard kill. - Build a minimal mTLS pair. Generate a client certificate signed by the server's CA, set
ClientAuth: tls.RequireAndVerifyClientCert, and confirm a client presenting no certificate is rejected during the handshake itself, before any application code runs.
Frequently Asked Questions
Is InsecureSkipVerify: true ever an acceptable shortcut for talking to a self-signed server?
Only inside an isolated test harness that never ships. Setting it disables certificate verification entirely, so the client loses any way to tell a real server from an attacker impersonating it — exactly the authentication guarantee TLS exists to provide. The chapter's own local-testing example reaches for a proper x509.CertPool loaded with the self-signed certificate instead, which is the pattern worth reusing in real code.
Why does an ephemeral ECDHE key exchange matter if the server already has a certificate? Because a certificate only proves who signed a public key — it doesn't protect past traffic if that long-term private key is ever stolen. The handshake's key-exchange keypair is generated fresh and discarded right after use, which is what gives TLS forward secrecy: an attacker who steals tomorrow's private key still can't decrypt a session recorded today.
My TLS client fails with "certificate signed by unknown authority" even though server.crt looks fine — what's going on?
Check whether pool.AppendCertsFromPEM actually succeeded. It returns a bool, not an error, so a missing, malformed, or empty CA file silently leaves pool empty instead of raising anything at load time — the confusing failure only surfaces much later, during the handshake itself, disguised as an unrelated-looking authority error.
What's the difference between RootCAs and ClientCAs, and why do mTLS setups get them backwards?
RootCAs is what a client uses to verify the server's certificate; ClientCAs is what a server uses to verify the client's certificate during mutual TLS — mirror-image roles serving opposite directions of trust. They're easy to mix up specifically when client and server certificates happen to share an internal CA, since pointing both fields at the same pool still "works" until a genuinely different CA is introduced and the confusion becomes visible.
Connecting to a server by IP address instead of its hostname breaks certificate validation — why?
Because the address passed to tls.Dial does double duty: it's the address to connect to, and, unless tls.Config.ServerName is set explicitly, it's also the name checked against the server's certificate via SNI. A raw IP or an internal load-balancer hostname won't match a certificate issued for the public domain, so verification fails even though the certificate itself is perfectly valid — the fix is setting ServerName to the name the certificate was actually issued for.
Key Takeaways
- TLS adds confidentiality, integrity, and authentication on top of the plain TCP connections built earlier in the book.
tls.Listen/tls.Dialmirrornet.Listen/net.Dialclosely, so upgrading existing TCP code to TLS is largely a drop-in change.RootCAsandClientCAscontrol which certificates a client or server trusts — never bypass that check withInsecureSkipVerifyin production.- Mutual TLS extends the authentication guarantees to the client as well, enforced during the handshake itself.
- Ephemeral key exchange (ECDHE) is what gives modern TLS forward secrecy — check errors on every accept, read, and write around a TLS connection just as carefully as on a plain one.