Skip to main content

HTTPS and TLS in Go: Certificates, mTLS, and Production Setup

Published: March 5, 2020 Updated: August 28, 2026 Larry Qu 7 min read

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.ListenAndServeTLS for the simplest HTTPS setup
  • Always set ReadTimeout, WriteTimeout, IdleTimeout on 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 GetCertificate for zero-downtime certificate rotation
  • Add security headers (HSTS, X-Content-Type-Options, etc.) on top of TLS

Resources

Comments

👍 Was this article helpful?