TLS (Transport Layer Security) is non-negotiable for production services. Go’s crypto/tls package gives you fine-grained control over certificates, cipher suites, protocol versions, and mutual authentication. This guide covers everything from a basic HTTPS server to production-hardened TLS configuration and automated certificate renewal.
Basic HTTPS Server
The simplest possible HTTPS server — two extra lines compared to HTTP:
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, TLS! Protocol: %s\n", r.Proto)
})
log.Println("Starting HTTPS server on :443")
// cert.pem and key.pem must exist (see below for generating them)
err := http.ListenAndServeTLS(":443", "cert.pem", "key.pem", mux)
if err != nil {
log.Fatal(err)
}
}
Generating Certificates
Self-Signed (Development Only)
# Generate private key
openssl genrsa -out key.pem 2048
# Generate self-signed certificate (valid 1 year)
openssl req -new -x509 -sha256 -key key.pem -out cert.pem -days 365 \
-subj "/C=US/ST=CA/O=MyOrg/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
Or generate from Go itself (useful for tests):
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"time"
)
func generateSelfSigned(certFile, keyFile string) error {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return err
}
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
Organization: []string{"Dev"},
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
DNSNames: []string{"localhost"},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
return err
}
// Write cert
certOut, _ := os.Create(certFile)
defer certOut.Close()
pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: certDER})
// Write key
keyOut, _ := os.Create(keyFile)
defer keyOut.Close()
privDER, _ := x509.MarshalECPrivateKey(priv)
pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: privDER})
return nil
}
Let’s Encrypt (Production — Automated)
go get golang.org/x/crypto/acme/autocert
import (
"crypto/tls"
"golang.org/x/crypto/acme/autocert"
"net/http"
)
func main() {
// autocert handles certificate provisioning and renewal automatically
m := &autocert.Manager{
Cache: autocert.DirCache("/var/cache/autocert"), // store certs on disk
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist("example.com", "www.example.com"),
Email: "[email protected]",
}
tlsConfig := m.TLSConfig()
tlsConfig.MinVersion = tls.VersionTLS12
server := &http.Server{
Addr: ":443",
Handler: mux,
TLSConfig: tlsConfig,
}
// HTTP server that redirects to HTTPS and handles ACME HTTP-01 challenges
go http.ListenAndServe(":80", m.HTTPHandler(http.HandlerFunc(redirectHTTPS)))
log.Fatal(server.ListenAndServeTLS("", ""))
}
func redirectHTTPS(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "https://"+r.Host+r.RequestURI, http.StatusMovedPermanently)
}
Custom tls.Config — Hardened Settings
Go’s defaults are secure, but for production you may want explicit control:
import (
"crypto/tls"
"time"
)
func hardendedTLSConfig(certFile, keyFile string) (*tls.Config, error) {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, fmt.Errorf("loading certificate: %w", err)
}
return &tls.Config{
Certificates: []tls.Certificate{cert},
// Minimum TLS 1.2 — never allow 1.0 or 1.1
MinVersion: tls.VersionTLS12,
// Prefer TLS 1.3 when client supports it
// (Go automatically prefers TLS 1.3 when available)
// Explicit cipher suites for TLS 1.2
// TLS 1.3 cipher suites are not configurable (Go chooses them)
CipherSuites: []uint16{
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
},
// Session tickets for performance (TLS resumption)
SessionTicketsDisabled: false,
// ALPN: negotiate HTTP/2 first, then HTTP/1.1
NextProtos: []string{"h2", "http/1.1"},
// Curve preferences for ECDHE
CurvePreferences: []tls.CurveID{
tls.X25519,
tls.CurveP256,
},
}, nil
}
func newProductionServer(addr string, tlsConf *tls.Config, handler http.Handler) *http.Server {
return &http.Server{
Addr: addr,
Handler: handler,
TLSConfig: tlsConf,
// Timeouts prevent slow-client attacks
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
ReadHeaderTimeout: 5 * time.Second,
// Limit request body size
MaxHeaderBytes: 1 << 20, // 1MB
}
}
Low-Level TLS: Raw TCP with crypto/tls
For non-HTTP protocols (database drivers, message brokers, custom protocols):
// TLS Server
func runTLSServer(addr, certFile, keyFile string) error {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return err
}
config := &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
}
ln, err := tls.Listen("tcp", addr, config)
if err != nil {
return err
}
defer ln.Close()
log.Printf("TLS server listening on %s", addr)
for {
conn, err := ln.Accept()
if err != nil {
log.Printf("accept error: %v", err)
continue
}
go handleTLSConn(conn)
}
}
func handleTLSConn(conn net.Conn) {
defer conn.Close()
conn.SetDeadline(time.Now().Add(30 * time.Second))
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
return
}
conn.Write([]byte("echo: " + string(buf[:n])))
}
// TLS Client
func tlsClient(addr string) error {
config := &tls.Config{
MinVersion: tls.VersionTLS12,
// For production: use proper CA verification
// InsecureSkipVerify: false (default)
// For dev with self-signed cert:
InsecureSkipVerify: true,
}
conn, err := tls.Dial("tcp", addr, config)
if err != nil {
return err
}
defer conn.Close()
// Inspect the negotiated connection
state := conn.ConnectionState()
log.Printf("TLS version: %x", state.Version)
log.Printf("Cipher suite: %x", state.CipherSuite)
log.Printf("Server name: %s", state.ServerName)
for _, cert := range state.PeerCertificates {
log.Printf("Cert: %s (expires %s)", cert.Subject, cert.NotAfter)
}
conn.Write([]byte("hello tls\n"))
buf := make([]byte, 100)
n, _ := conn.Read(buf)
log.Printf("Response: %s", buf[:n])
return nil
}
Mutual TLS (mTLS)
mTLS requires the client to present a certificate too — used for service-to-service authentication:
import (
"crypto/tls"
"crypto/x509"
"os"
)
// mTLS Server: verifies client certificates
func mTLSServer(addr, serverCert, serverKey, caCert string) error {
// Load the CA that signed client certificates
caCertPEM, err := os.ReadFile(caCert)
if err != nil {
return err
}
caPool := x509.NewCertPool()
if !caPool.AppendCertsFromPEM(caCertPEM) {
return fmt.Errorf("failed to parse CA cert")
}
cert, err := tls.LoadX509KeyPair(serverCert, serverKey)
if err != nil {
return err
}
config := &tls.Config{
Certificates: []tls.Certificate{cert},
ClientAuth: tls.RequireAndVerifyClientCert, // mTLS: require client cert
ClientCAs: caPool,
MinVersion: tls.VersionTLS12,
}
ln, err := tls.Listen("tcp", addr, config)
if err != nil {
return err
}
defer ln.Close()
for {
conn, err := ln.Accept()
if err != nil {
continue
}
tlsConn := conn.(*tls.Conn)
// Access the verified client certificate
go func() {
defer conn.Close()
tlsConn.Handshake()
state := tlsConn.ConnectionState()
if len(state.PeerCertificates) > 0 {
log.Printf("Client cert: %s", state.PeerCertificates[0].Subject)
}
}()
}
}
// mTLS Client: presents a client certificate
func mTLSClient(addr, clientCert, clientKey, caCert string) error {
cert, err := tls.LoadX509KeyPair(clientCert, clientKey)
if err != nil {
return err
}
caCertPEM, _ := os.ReadFile(caCert)
caPool := x509.NewCertPool()
caPool.AppendCertsFromPEM(caCertPEM)
config := &tls.Config{
Certificates: []tls.Certificate{cert}, // present our cert
RootCAs: caPool, // trust server certs signed by this CA
MinVersion: tls.VersionTLS12,
}
conn, err := tls.Dial("tcp", addr, config)
if err != nil {
return err
}
defer conn.Close()
log.Println("mTLS handshake successful")
return nil
}
Certificate Reloading Without Restart
Reload certificates on SIGHUP for zero-downtime cert rotation:
import (
"crypto/tls"
"os"
"os/signal"
"sync"
"syscall"
)
type reloadableCert struct {
mu sync.RWMutex
cert *tls.Certificate
}
func (rc *reloadableCert) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
rc.mu.RLock()
defer rc.mu.RUnlock()
return rc.cert, nil
}
func (rc *reloadableCert) reload(certFile, keyFile string) error {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return err
}
rc.mu.Lock()
rc.cert = &cert
rc.mu.Unlock()
log.Println("Certificate reloaded")
return nil
}
func main() {
certFile, keyFile := "cert.pem", "key.pem"
rc := &reloadableCert{}
rc.reload(certFile, keyFile)
config := &tls.Config{
GetCertificate: rc.GetCertificate, // called per-handshake
MinVersion: tls.VersionTLS12,
}
// Reload on SIGHUP
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGHUP)
go func() {
for range sigs {
if err := rc.reload(certFile, keyFile); err != nil {
log.Printf("Reload failed: %v", err)
}
}
}()
server := &http.Server{Addr: ":443", TLSConfig: config}
server.ListenAndServeTLS("", "") // Empty strings — GetCertificate handles it
}
HTTPS Security Headers
TLS encrypts the channel, but HTTP headers add defense-in-depth:
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Force HTTPS for 1 year, including subdomains
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
// Prevent MIME type sniffing
w.Header().Set("X-Content-Type-Options", "nosniff")
// Prevent clickjacking
w.Header().Set("X-Frame-Options", "DENY")
// Enable XSS filter in older browsers
w.Header().Set("X-XSS-Protection", "1; mode=block")
// Referrer policy
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
next.ServeHTTP(w, r)
})
}
Troubleshooting
# Test TLS handshake and see negotiated cipher/version
openssl s_client -connect localhost:443 -tls1_2
# Check certificate expiry
openssl s_client -connect example.com:443 2>/dev/null | openssl x509 -noout -dates
# Verify certificate chain
openssl verify -CAfile ca.pem cert.pem
# Test with curl (verbose TLS info)
curl -v --tlsv1.2 https://localhost:443/
# Check which TLS versions are supported
nmap --script ssl-enum-ciphers -p 443 example.com
Summary
- Use
http.ListenAndServeTLSfor the simplest HTTPS setup - Always set
ReadTimeout,WriteTimeout,IdleTimeouton the server — prevent slow-client attacks - Set
MinVersion: tls.VersionTLS12— never allow TLS 1.0/1.1 - Use Let’s Encrypt (
autocert) for production — automated provisioning and renewal - Use mTLS for service-to-service authentication in zero-trust architectures
- Use
GetCertificatefor zero-downtime certificate rotation - Add security headers (HSTS, X-Content-Type-Options, etc.) on top of TLS
Resources
- Go crypto/tls package
- golang.org/x/crypto/acme/autocert
- Let’s Encrypt
- Mozilla TLS configuration generator
Comments