Implementing TLS/SSL in Go
"Every letter you've sent so far in this book traveled in a clear envelope anyone along the route could open and read. TLS is the sealed, tamper-evident envelope — and Go makes using one almost as easy as not bothering."
What TLS Actually Buys You
TLS (Transport Layer Security) — the modern successor to the now-obsolete SSL, though the name "SSL" persists in casual use — sits between the transport layer (TCP) and the application protocol (HTTP, SMTP, or your own custom protocol), providing three guarantees that map directly onto the CIA triad from Chapter 5.1:
- Confidentiality — the connection is encrypted, so a passive observer on the path (Chapter 5.5's sniffer, for instance) sees ciphertext, not your data.
- Integrity — TLS records include authentication, so tampering in transit is detectable.
- Authentication — the client can verify the server is actually who it claims to be, via a certificate chain (Chapter 5.12), which is what closes the man-in-the-middle door from Chapter 5.3.
Go's crypto/tls package implements the protocol directly in the standard library — no external dependency required, and it's actively maintained as part of Go's security process (Chapter 5.32 mentions the Go vulnerability database that tracks issues here).
The Handshake, Briefly
Before any application data flows, client and server perform a handshake: they agree on a TLS version and cipher suite, the server presents its certificate chain, the client verifies it against trusted root certificates, and both sides derive a shared symmetric key used to encrypt everything that follows. TLS 1.3 (the current version you should target) simplified this considerably compared to 1.2, cutting a round trip and removing several legacy, weaker options entirely. Chapter 3.19 walked through this same handshake and Go's crypto/tls client/server setup in more depth; this chapter takes it from there and into certificate handling specifics.
Go Implementation: A Minimal TLS Server
package main
import (
"crypto/tls"
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello over TLS\n"))
})
server := &http.Server{
Addr: ":8443",
Handler: mux,
TLSConfig: &tls.Config{
// reject anything older than TLS 1.2
MinVersion: tls.VersionTLS12,
},
}
// cert.pem/key.pem here would be a real certificate in production
// (Chapter 5.12) or a self-signed pair for local testing.
log.Fatal(server.ListenAndServeTLS("cert.pem", "key.pem"))
}
ListenAndServeTLS handles the handshake, record encryption, and connection lifecycle entirely — the handler code above is identical to a plain HTTP handler, which is one of the strongest arguments for using it: there's essentially no excuse for serving anything sensitive over plaintext HTTP when the encrypted version is this close to free.
Setting MinVersion: tls.VersionTLS12 explicitly is worth doing even though Go's defaults are already reasonable — it documents the intent in the code and protects against a future misconfiguration accidentally lowering it.
Go Implementation: A TLS Client That Actually Verifies
package main
import (
"crypto/tls"
"fmt"
"io"
"log"
"os"
)
func main() {
// tls.Dial performs the TCP connection and the TLS handshake together,
// verifying the server's certificate chain against the system trust store.
conn, err := tls.Dial("tcp", "example.com:443", &tls.Config{
MinVersion: tls.VersionTLS12,
// used for SNI and certificate hostname verification
ServerName: "example.com",
})
if err != nil {
log.Fatalf("tls dial: %v", err)
}
defer conn.Close()
state := conn.ConnectionState()
fmt.Printf("negotiated TLS version: %x, cipher suite: %x\n",
state.Version, state.CipherSuite)
fmt.Fprintf(conn,
"GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n")
if _, err := io.Copy(os.Stdout, conn); err != nil {
log.Fatalf("read response: %v", err)
}
}
Note what's not in this code: InsecureSkipVerify. That field exists on tls.Config and disables certificate verification entirely — useful only for narrow, controlled testing scenarios, and one of the most common serious mistakes in Go TLS code. Setting it to true in anything reaching a real network makes the client accept a certificate from literally anyone, silently reintroducing the exact man-in-the-middle risk TLS exists to prevent.
tls.Config{InsecureSkipVerify: true} will work perfectly in testing and then silently accept a forged certificate in production. If you need to trust a private or self-signed CA, add it to a custom RootCAs certificate pool (Chapter 5.12) instead of disabling verification altogether.Choosing Cipher Suites and Versions
For TLS 1.3, Go doesn't allow configuring cipher suites at all — the small, modern, carefully chosen set built into the protocol is used automatically, which removes an entire historical category of misconfiguration (accidentally allowing a weak cipher). For TLS 1.2 compatibility, tls.Config.CipherSuites lets you restrict the set explicitly if you need to support older clients while still excluding legacy weak options — though for most new services, simply setting MinVersion: tls.VersionTLS13 and moving on is the safer, simpler default when your clients support it.
Mutual TLS, in One Field
Setting ClientAuth: tls.RequireAndVerifyClientCert on a server's tls.Config flips the authentication around: the client must also present a valid certificate, verified by the server, before the handshake completes. This pattern — mutual TLS (mTLS) — is a cornerstone of zero trust network design, covered fully in Chapter 5.14.
Testing Your Own TLS Configuration
Once a service is serving TLS, it's worth routinely checking what it actually negotiates, not just what you intended to configure — a dependency upgrade or a copy-pasted config can quietly change behavior. The ConnectionState() call used in the client example above is one way to do this from Go directly; the header-auditing tool from Chapter 5.7 could just as easily be extended to record the negotiated TLS version and certificate expiry (Chapter 5.12) for every service in a fleet, turning "we think everything is on TLS 1.3" into something you actually verify on a schedule rather than assume.
Frequently Asked Questions
Isn't "SSL" and "TLS" just two names for the same thing, so it doesn't matter which I say?
In casual conversation, sure, and this chapter uses "TLS/SSL" in its own title for exactly that reason — but technically SSL is the older, now-obsolete protocol family that TLS replaced, and no modern Go code should ever negotiate an actual SSL version. When you write crypto/tls in Go, you are always working with TLS; the "SSL" name survives mostly out of habit from products and documentation that predate the rename.
My server works fine locally with InsecureSkipVerify: true but I've heard that's dangerous — why, if it "works"?
It works precisely because it's not checking anything — that flag tells the client to accept literally any certificate presented, valid or forged, which defeats the authentication guarantee that closes the man-in-the-middle door from Chapter 5.3. It looks identical to a correctly working connection in every test you'll run yourself, which is exactly what makes it so easy to accidentally ship; the fix from the chapter is to add your private or self-signed CA to a RootCAs pool instead of disabling verification.
Why does TLS 1.3 not let me choose cipher suites the way TLS 1.2 does? Because most historical TLS misconfigurations came from someone unknowingly leaving a weak or deprecated cipher enabled, so TLS 1.3 removes that entire decision from the table and ships with only a small, modern, carefully vetted set used automatically. It's a deliberate design choice to make the safe configuration the only configuration, rather than trusting every implementer to pick correctly.
What's the practical difference between the TLS work in this chapter and the mutual TLS mentioned for zero trust in Chapter 5.14?
This chapter's examples authenticate one direction — the client verifies the server's certificate, which is the ordinary browser-to-website model. Setting ClientAuth: tls.RequireAndVerifyClientCert flips that around so the server also demands and verifies a certificate from the client, and Chapter 5.14 builds on that single field as a cornerstone of verifying every connection by identity rather than by network location.