Skip to main content

Part 14: TLS: encrypting the wire, hot-reloading certificates, and verifying the database

Updated 35 min readMarkdown
Part of seriesBuild a production-grade REST API in Go

Every password and token in this series still crosses the network as plain text. We add HTTPS, learn what a certificate actually proves, rotate certificates without a restart, and encrypt (and verify) the connection to Postgres.


Series: Build a production-grade REST API in Go · Part 14 of 17 Where we are: the API from Part 13, still speaking plain HTTP.

Look at what we built in Parts 11-13: password hashing, signed tokens, rotation, rate limiting. All of it is undone if someone on the network path can read the traffic. A Wi-Fi hotspot, a compromised router or a proxy sees POST /auth/login {"password":"..."} and every Authorization: Bearer ... header in the clear. Tokens are bearer credentials: whoever holds one can use it.

TLS (the "S" in HTTPS) gives three guarantees:

  1. Confidentiality: nobody on the path can read the traffic.
  2. Integrity: nobody can modify it undetected.
  3. Authenticity: you're really talking to the server you intended, not an impostor.

The third is the one people forget, and it's where certificates come in.

How trust works (the 60-second version)

  1. The server holds a private key (never leaves the server) and a certificate: a document saying "this public key belongs to api.example.com", signed by a Certificate Authority (CA).
  2. Your operating system / browser / curl ships with a list of CAs it trusts. When it connects, it checks: does the signature chain lead to a trusted CA? Is the certificate unexpired? Is the hostname I dialed listed in the certificate's Subject Alternative Names (SAN)?
  3. Then both sides derive temporary session keys (ephemeral Diffie-Hellman) and encrypt. Because those keys are ephemeral, stealing the server's private key later can't decrypt traffic recorded earlier (forward secrecy).

Step 2 is the point. A man-in-the-middle can encrypt traffic just fine; what they cannot do is present a certificate for your hostname that a trusted CA signed.

Never "fix" a TLS error with --insecure or InsecureSkipVerify: true. It keeps the encryption but throws away step 2, so an attacker in the middle is accepted. It is the most common way TLS gets silently disabled in production code.

Step 1: certificates for development

We need a certificate to test with. A real one comes from a CA like Let's Encrypt; for development we make our own local CA and sign a server certificate with it. Why a CA rather than a single self-signed cert? Clients then trust one file (ca.pem), and verification works exactly as in production (chain and hostname checks), so nobody is tempted to disable it. It also lets us re-issue the server certificate (rotation) without clients changing what they trust.

internal/devcert/devcert.go (excerpt)

// NewCA creates a CA valid for one year.
func NewCA(now time.Time) (*CA, error) {
	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		return nil, err
	}
	tmpl := &x509.Certificate{
		SerialNumber:          serial(),
		Subject:               pkix.Name{CommonName: "rest-go dev CA", Organization: []string{"rest-go (development only)"}},
		NotBefore:             now.Add(-time.Minute),
		NotAfter:              now.Add(365 * 24 * time.Hour),
		IsCA:                  true,
		BasicConstraintsValid: true,
		MaxPathLen:            0, // may sign leaf certs, not further CAs
		MaxPathLenZero:        true,
		KeyUsage:              x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
	}
	der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
	if err != nil {
		return nil, err
	}
	cert, err := x509.ParseCertificate(der)
	if err != nil {
		return nil, err
	}
	keyDER, err := x509.MarshalPKCS8PrivateKey(key)
	if err != nil {
		return nil, err
	}
	return &CA{
		cert: cert, key: key,
		CertPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
		KeyPEM:  pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}),
	}, nil
}

// IssueServer signs a server certificate valid for `validity` whose Subject
// Alternative Names are hosts (DNS names and/or IPs).
//
// SAN is what clients check. Since 2017 browsers and Go ignore the old "Common
// Name" field: a cert without a matching SAN fails with "certificate is not
// valid for any names" even if the CN matches.
func (ca *CA) IssueServer(hosts []string, now time.Time, validity time.Duration) (Server, error) {
	if len(hosts) == 0 {
		return Server{}, errors.New("at least one host required")
	}
	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		return Server{}, err
	}
	tmpl := &x509.Certificate{
		SerialNumber: serial(),
		Subject:      pkix.Name{CommonName: hosts[0]},
		NotBefore:    now.Add(-time.Minute),
		NotAfter:     now.Add(validity),
		KeyUsage:     x509.KeyUsageDigitalSignature,
		ExtKeyUsage:  []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
	}
	for _, h := range hosts {
		if ip := net.ParseIP(h); ip != nil {
			tmpl.IPAddresses = append(tmpl.IPAddresses, ip)
		} else {
			tmpl.DNSNames = append(tmpl.DNSNames, h)
		}
	}
	der, err := x509.CreateCertificate(rand.Reader, tmpl, ca.cert, &key.PublicKey, ca.key)
	if err != nil {
		return Server{}, err
	}
	keyDER, err := x509.MarshalPKCS8PrivateKey(key)
	if err != nil {
		return Server{}, err
	}
	return Server{
		CertPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
		KeyPEM:  pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}),
	}, nil
}

Highlights:

  • ECDSA P-256 keys: small, fast, modern.
  • IsCA + MaxPathLen: 0: the CA can sign server certificates but not create further CAs.
  • SAN entries (DNSNames, IPAddresses): Go and all modern browsers ignore the old "Common Name" field; a certificate without a matching SAN fails with "certificate is not valid for any names". Our dev cert covers localhost, 127.0.0.1, ::1, api, and postgres.
  • Validity: 90 days for the server, 1 year for the CA.

A tiny command wraps it (make certs → certs/ca.pem, server.pem, server-key.pem; the directory is git-ignored). This is development only: the key files are world-readable so containers can read them. In production the certificate comes from a real CA, the private key is readable only by the service account, and a CA key never sits on a server.

Step 2: serve HTTPS, with sensible defaults

Go makes the server part easy, so the interesting part is the choices:

cmd/rest/main.go (lines 268-279)

	// TLS: load the certificate now (fail fast) and keep it hot-reloadable.
	var tlsReloader *tlsutil.Reloader
	middlewares := []func(http.Handler) http.Handler{}
	if cfg.TLSEnabled() {
		if tlsReloader, err = tlsutil.NewReloader(cfg.TLSCertFile, cfg.TLSKeyFile, log); err != nil {
			return err
		}
		metrics.TrackCertExpiry(tlsReloader.Expiry)
		if cfg.HSTSMaxAge > 0 {
			middlewares = append(middlewares, middleware.HSTS(cfg.HSTSMaxAge))
		}
	}

internal/tlsutil/tlsutil.go (excerpt)

// Config returns the server TLS settings.
//
// Deliberately minimal: Go's defaults are maintained by the Go team and are
// better than a hand-written cipher list (old lists age badly and have shipped
// weak suites in many products). We only set the floor:
//   - TLS 1.2 minimum: TLS 1.0/1.1 are deprecated (RFC 8996) and have known attacks.
//   - TLS 1.3 is used automatically with modern clients; pass min=tls.VersionTLS13
//     when ALL clients are known to support it.
func (r *Reloader) Config(minVersion uint16) *tls.Config {
	return &tls.Config{
		MinVersion:     minVersion,
		GetCertificate: r.GetCertificate,
		// HTTP/2 is negotiated via ALPN; net/http adds "h2" itself when serving TLS.
	}
}

// ParseMinVersion maps "1.2" / "1.3" to the crypto/tls constant.
func ParseMinVersion(s string) (uint16, error) {
	switch s {
	case "", "1.2":
		return tls.VersionTLS12, nil
	case "1.3":
		return tls.VersionTLS13, nil
	}
	return 0, fmt.Errorf("TLS_MIN_VERSION must be 1.2 or 1.3, got %q", s)
}
  • MinVersion: TLS 1.2. TLS 1.0 and 1.1 are deprecated (RFC 8996) with known attacks. TLS 1.3 is negotiated automatically with any modern client. Set TLS_MIN_VERSION=1.3 once you know all your clients support it.
  • We do not hand-pick cipher suites. Go's defaults are maintained by the Go team and kept current; hand-written cipher lists age badly and have shipped weak suites in many products. The best configuration is the one you don't write.
  • HTTP/2 comes free. When serving TLS, net/http advertises h2 via ALPN; curl -v will show using HTTP/2.
  • ListenAndServeTLS("", "") with empty file names: certificates come from TLSConfig.GetCertificate, which is what makes hot reloading possible.
  • No plain-HTTP listener and no http→https redirect. For a website, redirecting is friendly. For an API, it's a trap: the client's first request already carried its Authorization header in clear text before any redirect could happen. An API should fail fast.
  • HSTS (Strict-Transport-Security): tells browsers to use HTTPS only for this host for the next N seconds, defeating "SSL stripping" downgrade attacks. Sent only over TLS, with a default of 180 days. Roll it out with a short value first: a long max-age on a host that later loses HTTPS locks users out.

internal/middleware/middleware.go (excerpt)

// HSTS tells browsers (and well-behaved clients) to use HTTPS only for this host
// for the next maxAge, even if someone types or links http://. It defeats
// "SSL stripping" downgrade attacks. Only send it over HTTPS: browsers ignore it
// on plain HTTP, and a long max-age on a host that later loses HTTPS locks
// users out. Start small when rolling out, and treat "preload" as irreversible.
func HSTS(maxAge time.Duration) func(http.Handler) http.Handler {
	value := "max-age=" + strconv.Itoa(int(maxAge.Seconds()))
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			w.Header().Set("Strict-Transport-Security", value)
			next.ServeHTTP(w, r)
		})
	}
}

Step 3: rotating certificates without downtime

Certificates expire (Let's Encrypt: every 90 days) and are renewed by an external process (certbot, cert-manager, your secrets platform) which rewrites the files. A server that read them once at startup keeps serving the old, soon-expired certificate until someone restarts it. That is a classic outage: "it worked for 89 days."

So the server re-reads the files when they change:

internal/tlsutil/tlsutil.go (excerpt)

// Reloader serves a certificate that can be replaced WITHOUT restarting.
//
// Certificates expire (Let's Encrypt: 90 days) and are renewed by an external
// process (certbot, cert-manager, your secret store) that rewrites the files. A
// server that loads them once at startup keeps serving the old, soon-expired
// cert until someone restarts it: a classic outage. Here every handshake calls
// GetCertificate, which re-reads the files when their modification time changes.
type Reloader struct {
	certFile, keyFile string
	log               *slog.Logger
	interval          time.Duration
	now               func() time.Time

	mu        sync.RWMutex
	cert      *tls.Certificate
	leaf      *x509.Certificate
	certMod   time.Time
	keyMod    time.Time
	lastCheck time.Time
}

// NewReloader loads the pair immediately, so a missing or invalid cert fails at
// startup (loudly) rather than on the first client connection.
func NewReloader(certFile, keyFile string, log *slog.Logger) (*Reloader, error) {
	r := &Reloader{certFile: certFile, keyFile: keyFile, log: log, interval: 5 * time.Second, now: time.Now}
	if err := r.load(); err != nil {
		return nil, err
	}
	r.lastCheck = r.now()
	return r, nil
}

// GetCertificate is plugged into tls.Config; it runs on every handshake.
func (r *Reloader) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
	r.maybeReload()
	r.mu.RLock()
	defer r.mu.RUnlock()
	return r.cert, nil
}

func (r *Reloader) maybeReload() {
	r.mu.Lock()
	if r.now().Sub(r.lastCheck) < r.interval { // stat at most every few seconds, not per handshake
		r.mu.Unlock()
		return
	}
	r.lastCheck = r.now()
	cm, err1 := modTime(r.certFile)
	km, err2 := modTime(r.keyFile)
	changed := err1 == nil && err2 == nil && (!cm.Equal(r.certMod) || !km.Equal(r.keyMod))
	r.mu.Unlock()
	if !changed {
		return
	}
	// On failure KEEP SERVING THE OLD CERT. A half-written file (cert replaced,
	// key not yet) must not take the site down; the next check retries.
	if err := r.load(); err != nil {
		r.log.Error("tls certificate reload failed; keeping previous certificate", "err", err)
		return
	}
	r.log.Info("tls certificate reloaded", "expires", r.Expiry())
}
  • GetCertificate runs on every TLS handshake. maybeReload stats the files at most every 5 seconds and reloads when the modification time changed. (os.Stat follows symlinks, so Kubernetes' symlink-swap secret updates work.)
  • A bad new file keeps the old certificate serving. A half-written rotation (new cert, old key) must not take the site down; we log the error and retry on the next check. Both behaviors are unit-tested.
  • NewReloader loads immediately, so a missing or mismatched certificate fails at startup, not on the first client.
  • The expiry date is exported as a metric (tls_certificate_expiry_timestamp_seconds) with an alert at 14 days. Don't trust renewal automation blindly: monitor the outcome.

Step 4: encrypt the database connection too

The API-to-Postgres link crosses a network as well. Postgres supports TLS; the important part is the client's sslmode:

sslmode Encrypted? Server identity checked? Stops a man-in-the-middle?
disable no no no
prefer (the default!) maybe: silently falls back to plain no no
require yes no no
verify-ca yes CA only partly
verify-full yes CA and hostname yes

require is the common false comfort: the traffic is encrypted, but to whoever answers. Only verify-full checks that the server is who it claims, the same step-2 logic as HTTPS. Our connection string becomes:

postgres://app:...@postgres:5432/app?sslmode=verify-full&sslrootcert=/certs/ca.pem

In compose, Postgres starts with TLS on and a 1.2 floor. Postgres insists the private key be owned by the postgres user with mode 0600, which a bind mount can't guarantee, so we copy the key into place first:

docker-compose.yml (lines 17-34)

      - pgdata:/var/lib/postgresql/data      # named volume: data survives `down`
      - ./certs:/certs:ro                    # dev CA + server cert (`make certs`)
    # Postgres refuses a private key that is group/world readable or not owned by
    # the postgres user, which bind mounts can't guarantee, so copy it into place
    # first. Then start Postgres with TLS on and a 1.2 floor.
    command:
      - sh
      - -c
      - |
        cp /certs/server.pem /var/lib/postgresql/server.crt
        cp /certs/server-key.pem /var/lib/postgresql/server.key
        chown postgres:postgres /var/lib/postgresql/server.crt /var/lib/postgresql/server.key
        chmod 600 /var/lib/postgresql/server.key
        exec docker-entrypoint.sh postgres \
          -c ssl=on \
          -c ssl_cert_file=/var/lib/postgresql/server.crt \
          -c ssl_key_file=/var/lib/postgresql/server.key \
          -c ssl_min_protocol_version=TLSv1.2

Tests: attack your own server

The tests start a real TLS listener with our production config and attack it:

internal/tlsutil/tlsutil_test.go (excerpt)

// Start a real HTTPS server using the production TLS config and attack it.
func TestServerTLSBehaviour(t *testing.T) {
	dir := t.TempDir()
	ca := writeCerts(t, dir, "localhost", "127.0.0.1")
	r, err := tlsutil.NewReloader(filepath.Join(dir, "server.pem"), filepath.Join(dir, "server-key.pem"), quiet)
	if err != nil {
		t.Fatal(err)
	}
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatal(err)
	}
	srv := &http.Server{
		Handler:   http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }),
		TLSConfig: r.Config(tls.VersionTLS12),
	}
	go srv.ServeTLS(ln, "", "") // certificates come from GetCertificate
	t.Cleanup(func() { srv.Close() })
	url := "https://" + ln.Addr().String()

	pool := x509.NewCertPool()
	pool.AppendCertsFromPEM(ca)
	client := func(cfg *tls.Config) *http.Client {
		return &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{TLSClientConfig: cfg, ForceAttemptHTTP2: true}}
	}

	t.Run("trusted client connects over HTTP/2 with TLS 1.3", func(t *testing.T) {
		resp, err := client(&tls.Config{RootCAs: pool}).Get(url)
		if err != nil {
			t.Fatal(err)
		}
		resp.Body.Close()
		if resp.ProtoMajor != 2 || resp.TLS.Version != tls.VersionTLS13 {
			t.Fatalf("proto=%d tls=%x", resp.ProtoMajor, resp.TLS.Version)
		}
	})
	t.Run("TLS 1.1 clients are refused", func(t *testing.T) {
		_, err := client(&tls.Config{RootCAs: pool, MaxVersion: tls.VersionTLS11}).Get(url)
		if err == nil {
			t.Fatal("old protocol version was accepted")
		}
	})
	t.Run("client that does not trust our CA refuses the server", func(t *testing.T) {
		if _, err := client(&tls.Config{}).Get(url); err == nil {
			t.Fatal("unknown CA accepted")
		}
	})
	t.Run("hostname must match the certificate SAN", func(t *testing.T) {
		if _, err := client(&tls.Config{RootCAs: pool, ServerName: "evil.example.com"}).Get(url); err == nil {
			t.Fatal("wrong hostname accepted")
		}
	})
	t.Run("plain HTTP to the TLS port fails", func(t *testing.T) {
		resp, err := http.Get("http://" + ln.Addr().String())
		if err == nil {
			defer resp.Body.Close()
			if resp.StatusCode == 200 {
				t.Fatal("served plaintext")
			}
		}
	})
}

Each subtest is a security property: a trusted client gets HTTP/2 over TLS 1.3; a TLS 1.1 client is refused; a client without our CA refuses us; a wrong hostname is refused; plain HTTP to the TLS port is not served. Further tests cover the rotation and "keep the old certificate when the new files are broken" behaviors (using a polling loop rather than a fixed sleep, so tests are neither flaky nor slow).

Complete code for this part

Everything to type, file by file: the certificate generator and its command, the TLS reloader, and the edits that switch the API and Postgres to TLS. Then go mod tidy && go build ./....

Change internal/config/config.go

--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -46,6 +46,13 @@ type Config struct {
 	RateLimitBurst int
 	TrustedProxies string // CIDRs whose X-Forwarded-For we believe, e.g. "10.0.0.0/8"
 
+	// TLS. Set both files to serve HTTPS; leave empty for plain HTTP (e.g. when a
+	// load balancer terminates TLS in front of us).
+	TLSCertFile string
+	TLSKeyFile  string
+	TLSMinVer   string        // "1.2" (default) or "1.3"
+	HSTSMaxAge  time.Duration // Strict-Transport-Security lifetime; 0 disables. Only sent over TLS.
+
 	// Per-user limit for authenticated requests (0 disables). Keyed by user id, so
 	// people sharing an IP (office, mobile carrier NAT) don't share a budget.
 	UserRateLimitRPS   float64
@@ -95,6 +102,10 @@ func Load() (Config, error) {
 		ServiceName:         env("SERVICE_NAME", "rest-go"),
 		OTLPEndpoint:        os.Getenv("OTLP_ENDPOINT"),
 		TraceSampleRatio:    1.0,
+		TLSCertFile:         os.Getenv("TLS_CERT_FILE"),
+		TLSKeyFile:          os.Getenv("TLS_KEY_FILE"),
+		TLSMinVer:           env("TLS_MIN_VERSION", "1.2"),
+		HSTSMaxAge:          180 * 24 * time.Hour,
 		UserRateLimitRPS:    30,
 		UserRateLimitBurst:  60,
 		AuthRateLimitPerMin: 10,
@@ -128,6 +139,7 @@ func Load() (Config, error) {
 		{"ACCESS_TOKEN_TTL", &c.AccessTokenTTL},
 		{"REFRESH_TOKEN_TTL", &c.RefreshTokenTTL},
 		{"LOGIN_FAILURE_REFILL", &c.LoginFailureRefill},
+		{"HSTS_MAX_AGE", &c.HSTSMaxAge},
 	}
 	for _, d := range durations {
 		if v := os.Getenv(d.key); v != "" {
@@ -209,6 +221,12 @@ func Load() (Config, error) {
 		return c, fmt.Errorf("REQUEST_TIMEOUT (%s) must be below WRITE_TIMEOUT (%s)", c.RequestTimeout, c.WriteTimeout)
 	case c.DefaultPageSize < 1 || c.MaxPageSize < c.DefaultPageSize:
 		return c, fmt.Errorf("need 1 <= DEFAULT_PAGE_SIZE <= MAX_PAGE_SIZE")
+	case (c.TLSCertFile == "") != (c.TLSKeyFile == ""):
+		return c, fmt.Errorf("set both TLS_CERT_FILE and TLS_KEY_FILE, or neither")
+	case c.TLSMinVer != "1.2" && c.TLSMinVer != "1.3":
+		return c, fmt.Errorf("TLS_MIN_VERSION must be 1.2 or 1.3")
+	case c.HSTSMaxAge < 0:
+		return c, fmt.Errorf("HSTS_MAX_AGE must be >= 0")
 	case c.BcryptCost < 4 || c.BcryptCost > 16:
 		return c, fmt.Errorf("BCRYPT_COST must be within [4,16]")
 	case c.AccessTokenTTL <= 0 || c.RefreshTokenTTL <= c.AccessTokenTTL:
@@ -229,6 +247,8 @@ func Load() (Config, error) {
 
 // ValidateServe checks settings only the API server needs, so one-shot commands
 // (migrate, healthcheck) don't have to be given the JWT secret.
+func (c Config) TLSEnabled() bool { return c.TLSCertFile != "" }
+
 func (c Config) ValidateServe() error {
 	if len(c.JWTSecret) < 32 {
 		return fmt.Errorf("JWT_SECRET is required and must be at least 32 bytes (try: openssl rand -hex 32)")

Create internal/devcert/devcert.go

// Package devcert creates a throwaway certificate authority and server
// certificates signed by it, for LOCAL DEVELOPMENT and tests only.
//
// Why a CA instead of one self-signed cert? Clients then trust ONE file (ca.pem)
// and verification works exactly as in production (chain + hostname check), so
// you never need "--insecure" / InsecureSkipVerify, which is the habit that
// leaks into production code. It also lets us re-issue the server certificate
// (rotation) without clients having to change what they trust.
package devcert

import (
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/x509"
	"crypto/x509/pkix"
	"encoding/pem"
	"errors"
	"fmt"
	"math/big"
	"net"
	"os"
	"path/filepath"
	"time"
)

// CA is a certificate authority able to sign server certificates.
type CA struct {
	cert    *x509.Certificate
	key     *ecdsa.PrivateKey
	CertPEM []byte // give this to clients as their trust root
	KeyPEM  []byte // the CA's PRIVATE key: whoever has it can mint trusted certs
}

// Server is an issued server certificate.
type Server struct {
	CertPEM []byte
	KeyPEM  []byte // the server's PRIVATE key
}

// NewCA creates a CA valid for one year.
func NewCA(now time.Time) (*CA, error) {
	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		return nil, err
	}
	tmpl := &x509.Certificate{
		SerialNumber:          serial(),
		Subject:               pkix.Name{CommonName: "rest-go dev CA", Organization: []string{"rest-go (development only)"}},
		NotBefore:             now.Add(-time.Minute),
		NotAfter:              now.Add(365 * 24 * time.Hour),
		IsCA:                  true,
		BasicConstraintsValid: true,
		MaxPathLen:            0, // may sign leaf certs, not further CAs
		MaxPathLenZero:        true,
		KeyUsage:              x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
	}
	der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
	if err != nil {
		return nil, err
	}
	cert, err := x509.ParseCertificate(der)
	if err != nil {
		return nil, err
	}
	keyDER, err := x509.MarshalPKCS8PrivateKey(key)
	if err != nil {
		return nil, err
	}
	return &CA{
		cert: cert, key: key,
		CertPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
		KeyPEM:  pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}),
	}, nil
}

// LoadCA restores a CA written by WriteDir.
func LoadCA(certPEM, keyPEM []byte) (*CA, error) {
	cb, _ := pem.Decode(certPEM)
	kb, _ := pem.Decode(keyPEM)
	if cb == nil || kb == nil {
		return nil, errors.New("invalid CA PEM")
	}
	cert, err := x509.ParseCertificate(cb.Bytes)
	if err != nil {
		return nil, err
	}
	k, err := x509.ParsePKCS8PrivateKey(kb.Bytes)
	if err != nil {
		return nil, err
	}
	key, ok := k.(*ecdsa.PrivateKey)
	if !ok {
		return nil, errors.New("CA key is not ECDSA")
	}
	return &CA{cert: cert, key: key, CertPEM: certPEM, KeyPEM: keyPEM}, nil
}

// IssueServer signs a server certificate valid for `validity` whose Subject
// Alternative Names are hosts (DNS names and/or IPs).
//
// SAN is what clients check. Since 2017 browsers and Go ignore the old "Common
// Name" field: a cert without a matching SAN fails with "certificate is not
// valid for any names" even if the CN matches.
func (ca *CA) IssueServer(hosts []string, now time.Time, validity time.Duration) (Server, error) {
	if len(hosts) == 0 {
		return Server{}, errors.New("at least one host required")
	}
	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		return Server{}, err
	}
	tmpl := &x509.Certificate{
		SerialNumber: serial(),
		Subject:      pkix.Name{CommonName: hosts[0]},
		NotBefore:    now.Add(-time.Minute),
		NotAfter:     now.Add(validity),
		KeyUsage:     x509.KeyUsageDigitalSignature,
		ExtKeyUsage:  []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
	}
	for _, h := range hosts {
		if ip := net.ParseIP(h); ip != nil {
			tmpl.IPAddresses = append(tmpl.IPAddresses, ip)
		} else {
			tmpl.DNSNames = append(tmpl.DNSNames, h)
		}
	}
	der, err := x509.CreateCertificate(rand.Reader, tmpl, ca.cert, &key.PublicKey, ca.key)
	if err != nil {
		return Server{}, err
	}
	keyDER, err := x509.MarshalPKCS8PrivateKey(key)
	if err != nil {
		return Server{}, err
	}
	return Server{
		CertPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
		KeyPEM:  pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}),
	}, nil
}

func serial() *big.Int {
	n, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 127))
	if err != nil {
		panic(err)
	}
	return n
}

// WriteCA writes ca.pem and ca-key.pem; WriteServer writes server.pem and
// server-key.pem.
//
// DEV ONLY: files are written 0644 (group/world readable) because bind-mounted
// into containers they must be readable by non-root users (the api runs as uid
// 65532). In production the orchestrator mounts secrets with proper ownership,
// and the CA key never leaves the CA.
func WriteCA(dir string, ca *CA) error {
	return writeFiles(dir, map[string][]byte{"ca.pem": ca.CertPEM, "ca-key.pem": ca.KeyPEM})
}

func WriteServer(dir string, s Server) error {
	return writeFiles(dir, map[string][]byte{"server.pem": s.CertPEM, "server-key.pem": s.KeyPEM})
}

func writeFiles(dir string, files map[string][]byte) error {
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return err
	}
	for name, data := range files {
		if err := os.WriteFile(filepath.Join(dir, name), data, 0o644); err != nil {
			return fmt.Errorf("write %s: %w", name, err)
		}
	}
	return nil
}

// ReadCA loads the CA previously written to dir.
func ReadCA(dir string) (*CA, error) {
	c, err1 := os.ReadFile(filepath.Join(dir, "ca.pem"))
	k, err2 := os.ReadFile(filepath.Join(dir, "ca-key.pem"))
	if err := errors.Join(err1, err2); err != nil {
		return nil, err
	}
	return LoadCA(c, k)
}

Change internal/middleware/middleware.go

--- a/internal/middleware/middleware.go
+++ b/internal/middleware/middleware.go
@@ -58,6 +58,21 @@ func SecurityHeaders(next http.Handler) http.Handler {
 	})
 }
 
+// HSTS tells browsers (and well-behaved clients) to use HTTPS only for this host
+// for the next maxAge, even if someone types or links http://. It defeats
+// "SSL stripping" downgrade attacks. Only send it over HTTPS: browsers ignore it
+// on plain HTTP, and a long max-age on a host that later loses HTTPS locks
+// users out. Start small when rolling out, and treat "preload" as irreversible.
+func HSTS(maxAge time.Duration) func(http.Handler) http.Handler {
+	value := "max-age=" + strconv.Itoa(int(maxAge.Seconds()))
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			w.Header().Set("Strict-Transport-Security", value)
+			next.ServeHTTP(w, r)
+		})
+	}
+}
+
 // Timeout puts a deadline on each request's context. Handlers pass r.Context()
 // to the store, so a slow query is cancelled when the deadline hits instead of
 // running on after the client has given up.

Change internal/observability/metrics.go

--- a/internal/observability/metrics.go
+++ b/internal/observability/metrics.go
@@ -63,6 +63,15 @@ func (m *Metrics) RateLimited(limiter string) func() {
 	return c.Inc
 }
 
+// TrackCertExpiry exports the serving certificate's expiry as a gauge, read at
+// scrape time (so it follows hot reloads). Alert on it BEFORE it hits zero.
+func (m *Metrics) TrackCertExpiry(expiry func() time.Time) {
+	m.Registry.MustRegister(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
+		Name: "tls_certificate_expiry_timestamp_seconds",
+		Help: "Unix time at which the serving TLS certificate expires.",
+	}, func() float64 { return float64(expiry().Unix()) }))
+}
+
 // Handler serves the registry in Prometheus text format.
 func (m *Metrics) Handler() http.Handler {
 	return promhttp.HandlerFor(m.Registry, promhttp.HandlerOpts{})

Create internal/tlsutil/tlsutil.go

// Package tlsutil builds the server's TLS configuration.
package tlsutil

import (
	"crypto/tls"
	"crypto/x509"
	"fmt"
	"log/slog"
	"os"
	"sync"
	"time"
)

// Reloader serves a certificate that can be replaced WITHOUT restarting.
//
// Certificates expire (Let's Encrypt: 90 days) and are renewed by an external
// process (certbot, cert-manager, your secret store) that rewrites the files. A
// server that loads them once at startup keeps serving the old, soon-expired
// cert until someone restarts it: a classic outage. Here every handshake calls
// GetCertificate, which re-reads the files when their modification time changes.
type Reloader struct {
	certFile, keyFile string
	log               *slog.Logger
	interval          time.Duration
	now               func() time.Time

	mu        sync.RWMutex
	cert      *tls.Certificate
	leaf      *x509.Certificate
	certMod   time.Time
	keyMod    time.Time
	lastCheck time.Time
}

// NewReloader loads the pair immediately, so a missing or invalid cert fails at
// startup (loudly) rather than on the first client connection.
func NewReloader(certFile, keyFile string, log *slog.Logger) (*Reloader, error) {
	r := &Reloader{certFile: certFile, keyFile: keyFile, log: log, interval: 5 * time.Second, now: time.Now}
	if err := r.load(); err != nil {
		return nil, err
	}
	r.lastCheck = r.now()
	return r, nil
}

func (r *Reloader) load() error {
	pair, err := tls.LoadX509KeyPair(r.certFile, r.keyFile) // also checks cert matches key
	if err != nil {
		return fmt.Errorf("load tls keypair: %w", err)
	}
	leaf, err := x509.ParseCertificate(pair.Certificate[0])
	if err != nil {
		return fmt.Errorf("parse certificate: %w", err)
	}
	cm, err1 := modTime(r.certFile)
	km, err2 := modTime(r.keyFile)
	if err1 != nil || err2 != nil {
		return fmt.Errorf("stat tls files: %v %v", err1, err2)
	}
	r.mu.Lock()
	r.cert, r.leaf, r.certMod, r.keyMod = &pair, leaf, cm, km
	r.mu.Unlock()
	return nil
}

func modTime(path string) (time.Time, error) {
	fi, err := os.Stat(path) // follows symlinks (Kubernetes swaps a symlink on secret update)
	if err != nil {
		return time.Time{}, err
	}
	return fi.ModTime(), nil
}

// GetCertificate is plugged into tls.Config; it runs on every handshake.
func (r *Reloader) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
	r.maybeReload()
	r.mu.RLock()
	defer r.mu.RUnlock()
	return r.cert, nil
}

func (r *Reloader) maybeReload() {
	r.mu.Lock()
	if r.now().Sub(r.lastCheck) < r.interval { // stat at most every few seconds, not per handshake
		r.mu.Unlock()
		return
	}
	r.lastCheck = r.now()
	cm, err1 := modTime(r.certFile)
	km, err2 := modTime(r.keyFile)
	changed := err1 == nil && err2 == nil && (!cm.Equal(r.certMod) || !km.Equal(r.keyMod))
	r.mu.Unlock()
	if !changed {
		return
	}
	// On failure KEEP SERVING THE OLD CERT. A half-written file (cert replaced,
	// key not yet) must not take the site down; the next check retries.
	if err := r.load(); err != nil {
		r.log.Error("tls certificate reload failed; keeping previous certificate", "err", err)
		return
	}
	r.log.Info("tls certificate reloaded", "expires", r.Expiry())
}

// Expiry is the current certificate's NotAfter (exported as a metric).
func (r *Reloader) Expiry() time.Time {
	r.mu.RLock()
	defer r.mu.RUnlock()
	return r.leaf.NotAfter
}

// Config returns the server TLS settings.
//
// Deliberately minimal: Go's defaults are maintained by the Go team and are
// better than a hand-written cipher list (old lists age badly and have shipped
// weak suites in many products). We only set the floor:
//   - TLS 1.2 minimum: TLS 1.0/1.1 are deprecated (RFC 8996) and have known attacks.
//   - TLS 1.3 is used automatically with modern clients; pass min=tls.VersionTLS13
//     when ALL clients are known to support it.
func (r *Reloader) Config(minVersion uint16) *tls.Config {
	return &tls.Config{
		MinVersion:     minVersion,
		GetCertificate: r.GetCertificate,
		// HTTP/2 is negotiated via ALPN; net/http adds "h2" itself when serving TLS.
	}
}

// ParseMinVersion maps "1.2" / "1.3" to the crypto/tls constant.
func ParseMinVersion(s string) (uint16, error) {
	switch s {
	case "", "1.2":
		return tls.VersionTLS12, nil
	case "1.3":
		return tls.VersionTLS13, nil
	}
	return 0, fmt.Errorf("TLS_MIN_VERSION must be 1.2 or 1.3, got %q", s)
}

Create cmd/devcert/main.go

// Command devcert generates a local CA and server certificate for development.
//
//	go run ./cmd/devcert -out certs            # new CA + server cert
//	go run ./cmd/devcert -out certs -renew     # new server cert, SAME CA (simulates rotation)
package main

import (
	"flag"
	"fmt"
	"os"
	"strings"
	"time"

	"go-rest/internal/devcert"
)

func main() {
	out := flag.String("out", "certs", "output directory")
	hosts := flag.String("hosts", "localhost,127.0.0.1,::1,api,postgres", "comma-separated DNS names / IPs for the certificate")
	days := flag.Int("days", 90, "server certificate validity in days")
	renew := flag.Bool("renew", false, "reuse the existing CA in -out and only re-issue the server certificate")
	flag.Parse()

	if err := run(*out, strings.Split(*hosts, ","), time.Duration(*days)*24*time.Hour, *renew); err != nil {
		fmt.Fprintln(os.Stderr, "devcert:", err)
		os.Exit(1)
	}
	fmt.Printf("wrote %s/ (server cert valid %d days)\ntrust it with: curl --cacert %s/ca.pem https://localhost:7800/...\n", *out, *days, *out)
}

func run(dir string, hosts []string, validity time.Duration, renew bool) error {
	var (
		ca  *devcert.CA
		err error
	)
	if renew {
		ca, err = devcert.ReadCA(dir)
	} else {
		if ca, err = devcert.NewCA(time.Now()); err == nil {
			err = devcert.WriteCA(dir, ca)
		}
	}
	if err != nil {
		return err
	}
	srv, err := ca.IssueServer(hosts, time.Now(), validity)
	if err != nil {
		return err
	}
	return devcert.WriteServer(dir, srv)
}

Change cmd/rest/main.go

--- a/cmd/rest/main.go
+++ b/cmd/rest/main.go
@@ -25,6 +25,7 @@ import (
 	"go-rest/internal/ratelimit"
 	"go-rest/internal/routes"
 	"go-rest/internal/storage"
+	"go-rest/internal/tlsutil"
 )
 
 // One binary, three modes:
@@ -264,6 +265,19 @@ func serve() error {
 		AuthLimit:    authLimit,
 	})
 
+	// TLS: load the certificate now (fail fast) and keep it hot-reloadable.
+	var tlsReloader *tlsutil.Reloader
+	middlewares := []func(http.Handler) http.Handler{}
+	if cfg.TLSEnabled() {
+		if tlsReloader, err = tlsutil.NewReloader(cfg.TLSCertFile, cfg.TLSKeyFile, log); err != nil {
+			return err
+		}
+		metrics.TrackCertExpiry(tlsReloader.Expiry)
+		if cfg.HSTSMaxAge > 0 {
+			middlewares = append(middlewares, middleware.HSTS(cfg.HSTSMaxAge))
+		}
+	}
+
 	// First listed = outermost. ORDER MATTERS:
 	//  1. otelhttp, RequestID, Timeout all COPY the request (r.WithContext), so
 	//     they go first.
@@ -276,7 +290,7 @@ func serve() error {
 	//     like any response, yet rejected requests never reach the mux.
 	api := &http.Server{
 		Addr: cfg.Addr,
-		Handler: middleware.Chain(mux, append([]func(http.Handler) http.Handler{
+		Handler: middleware.Chain(mux, append(append(middlewares, []func(http.Handler) http.Handler{
 			func(next http.Handler) http.Handler { return otelhttp.NewHandler(next, "http.server") },
 			middleware.RequestID,
 			middleware.Gzip,
@@ -286,7 +300,7 @@ func serve() error {
 			middleware.Logging(log),
 			metrics.Middleware,
 			middleware.Recover(log),
-		}, limiters...)...),
+		}...), limiters...)...),
 		// The zero-value http.Server has NO timeouts, so slow or malicious
 		// clients (e.g. Slowloris) can hold connections open forever.
 		ReadHeaderTimeout: cfg.ReadHeaderTimeout,
@@ -295,6 +309,14 @@ func serve() error {
 		IdleTimeout:       cfg.IdleTimeout,
 	}
 
+	if tlsReloader != nil {
+		minVer, err := tlsutil.ParseMinVersion(cfg.TLSMinVer)
+		if err != nil {
+			return err
+		}
+		api.TLSConfig = tlsReloader.Config(minVer)
+	}
+
 	// Operational endpoints live on a SEPARATE port: metrics and health are for
 	// Prometheus/orchestrators, and must never be exposed through the public
 	// listener / ingress.
@@ -307,7 +329,15 @@ func serve() error {
 	errc := make(chan error, 2)
 	for name, srv := range map[string]*http.Server{"api": api, "admin": admin} {
 		go func() {
-			log.Info("listening", "server", name, "addr", srv.Addr)
+			log.Info("listening", "server", name, "addr", srv.Addr, "tls", srv.TLSConfig != nil)
+			if srv.TLSConfig != nil {
+				// Empty file names: certificates come from TLSConfig.GetCertificate.
+				// There is intentionally NO plain-HTTP listener or redirect for the
+				// API: a client's first request would already carry its Authorization
+				// header in clear text before any redirect could happen.
+				errc <- srv.ListenAndServeTLS("", "")
+				return
+			}
 			errc <- srv.ListenAndServe()
 		}()
 	}

Replace the contents of .gitignore with

users.db
users.db-shm
users.db-wal
bin/
.env
certs/

Change deploy/prometheus/alerts.yml

--- a/deploy/prometheus/alerts.yml
+++ b/deploy/prometheus/alerts.yml
@@ -41,3 +41,11 @@ groups:
         for: 5m
         labels: { severity: page }
         annotations: { summary: "Sustained throttling of login attempts; possible password guessing or credential stuffing" }
+
+  - name: tls
+    rules:
+      - alert: TLSCertificateExpiringSoon   # renewal automation has probably failed
+        expr: tls_certificate_expiry_timestamp_seconds - time() < 14 * 24 * 3600
+        for: 1h
+        labels: { severity: page }
+        annotations: { summary: "TLS certificate expires in under 14 days" }

Change docker-compose.yml

--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,4 +1,4 @@
-# Full local stack. `make up` -> API :7800, Grafana :3000, Prometheus :9091, Jaeger :16686
+# Full local stack. `make up` -> API https://localhost:7800, Grafana :3000, Prometheus :9091, Jaeger :16686
 name: rest-go
 
 x-logging: &logging   # cap container log files so disks don't fill up
@@ -15,6 +15,23 @@ services:
       POSTGRES_DB: ${POSTGRES_DB:-app}
     volumes:
       - pgdata:/var/lib/postgresql/data      # named volume: data survives `down`
+      - ./certs:/certs:ro                    # dev CA + server cert (`make certs`)
+    # Postgres refuses a private key that is group/world readable or not owned by
+    # the postgres user, which bind mounts can't guarantee, so copy it into place
+    # first. Then start Postgres with TLS on and a 1.2 floor.
+    command:
+      - sh
+      - -c
+      - |
+        cp /certs/server.pem /var/lib/postgresql/server.crt
+        cp /certs/server-key.pem /var/lib/postgresql/server.key
+        chown postgres:postgres /var/lib/postgresql/server.crt /var/lib/postgresql/server.key
+        chmod 600 /var/lib/postgresql/server.key
+        exec docker-entrypoint.sh postgres \
+          -c ssl=on \
+          -c ssl_cert_file=/var/lib/postgresql/server.crt \
+          -c ssl_key_file=/var/lib/postgresql/server.key \
+          -c ssl_min_protocol_version=TLSv1.2
     ports:
       - "127.0.0.1:5432:5432"                # localhost only, never 0.0.0.0
     healthcheck:                             # lets other services wait until it is READY
@@ -31,7 +48,8 @@ services:
     build: .
     command: ["migrate"]
     environment: &dburl
-      DATABASE_URL: postgres://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-change-me}@postgres:5432/${POSTGRES_DB:-app}?sslmode=disable
+      DATABASE_URL: postgres://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-change-me}@postgres:5432/${POSTGRES_DB:-app}?sslmode=verify-full&sslrootcert=/certs/ca.pem
+    volumes: ["./certs:/certs:ro"]
     depends_on:
       postgres: { condition: service_healthy }
     restart: "no"
@@ -43,6 +61,10 @@ services:
     environment:
       <<: *dburl
       ADDR: ":7800"
+      # HTTPS. The certificate is re-read when the files change (no restart needed).
+      TLS_CERT_FILE: /certs/server.pem
+      TLS_KEY_FILE: /certs/server-key.pem
+      # TLS_MIN_VERSION: "1.3"      # when every client supports it
       # Required, >= 32 bytes. `make up` generates one into .env on first run.
       JWT_SECRET: ${JWT_SECRET:?JWT_SECRET missing; run `make .env` or set it in .env}
       ADMIN_ADDR: ":9090"
@@ -59,8 +81,9 @@ services:
       LOGIN_MAX_FAILURES:
       LOGIN_FAILURE_REFILL:
       TRUSTED_PROXIES:              # set e.g. 10.0.0.0/8 when behind a load balancer
+    volumes: ["./certs:/certs:ro"]
     ports:
-      - "7800:7800"                          # public API only; admin port 9090 stays internal
+      - "7800:7800"                          # public API (HTTPS); admin port 9090 stays internal
     depends_on:
       migrate: { condition: service_completed_successfully }
       jaeger: { condition: service_started }
@@ -85,7 +108,8 @@ services:
   postgres-exporter:                         # turns Postgres internals into metrics
     image: prometheuscommunity/postgres-exporter:latest
     environment:
-      DATA_SOURCE_NAME: postgres://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-change-me}@postgres:5432/${POSTGRES_DB:-app}?sslmode=disable
+      DATA_SOURCE_NAME: postgres://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-change-me}@postgres:5432/${POSTGRES_DB:-app}?sslmode=verify-full&sslrootcert=/certs/ca.pem
+    volumes: ["./certs:/certs:ro"]
     depends_on:
       postgres: { condition: service_healthy }
     logging: *logging

Change makefile

--- a/makefile
+++ b/makefile
@@ -1,8 +1,9 @@
-.PHONY: admin db run build test test-integration up down logs reset migrate psql load clean
+.PHONY: admin certs rotate-certs db run build test test-integration up down logs reset migrate psql load clean
 
 BINARY := bin/server
 # Local (non-Docker) default; matches the compose Postgres published on localhost.
-export DATABASE_URL ?= postgres://app:change-me@localhost:5432/app?sslmode=disable
+# verify-full = encrypted AND the server's certificate + hostname are checked.
+export DATABASE_URL ?= postgres://app:change-me@localhost:5432/app?sslmode=verify-full&sslrootcert=$(CURDIR)/certs/ca.pem
 # Local `make run` needs a secret too; any 32+ byte value works for development.
 export JWT_SECRET ?= dev-only-secret-dev-only-secret-0123456789
 TEST_DATABASE_URL ?= $(DATABASE_URL)
@@ -22,12 +23,12 @@ test:                     ## unit tests (database tests are skipped)
 	go vet ./...
 	go test -race ./...
 
-test-integration:         ## all tests incl. database tests, against compose Postgres
+test-integration: certs/server.pem ## all tests incl. database tests, against compose Postgres (over TLS)
 	docker compose up -d --wait postgres
 	TEST_DATABASE_URL="$(TEST_DATABASE_URL)" go test -race -count=1 ./...
 
 # ---- Docker ------------------------------------------------------------------
-db:                       ## only Postgres (for running the API with `make run`)
+db: certs/server.pem      ## only Postgres (for running the API with `make run`)
 	docker compose up -d --wait postgres
 
 .env:                     ## create .env with a fresh random JWT secret (never overwrites)
@@ -38,7 +39,15 @@ db:                       ## only Postgres (for running the API with `make run`)
 admin:                    ## create/promote an admin:  ADMIN_EMAIL=a@b.co ADMIN_PASSWORD=... make admin
 	docker compose run --rm --no-deps -e ADMIN_EMAIL -e ADMIN_PASSWORD api create-admin
 
-up: .env                  ## whole stack: API + Postgres + Prometheus + Grafana + Jaeger + Loki
+certs/server.pem:         ## dev CA + server certificate (git-ignored)
+	go run ./cmd/devcert -out certs
+
+certs: certs/server.pem
+
+rotate-certs:             ## re-issue the server cert from the same CA; the running API picks it up
+	go run ./cmd/devcert -out certs -renew
+
+up: .env certs/server.pem                  ## whole stack: API + Postgres + Prometheus + Grafana + Jaeger + Loki
 	docker compose up -d --build --wait
 
 down:                     ## stop, KEEP data

Replace the contents of scripts/load.sh with

#!/usr/bin/env bash
# Generates mixed traffic (including errors) so the dashboards have something to show.
# Registers a throwaway user per run, then uses its token.
set -euo pipefail
API=${API:-https://localhost:7800}
# Trust the dev CA instead of disabling verification.
C="curl --cacert ${CA:-certs/ca.pem}"
EMAIL="load$RANDOM$RANDOM@example.com"
TOKEN=$($C -s -XPOST "$API/auth/register" \
  -d "{\"name\":\"load\",\"email\":\"$EMAIL\",\"password\":\"correct-horse-battery\"}" |
  python3 -c "import json,sys; print(json.load(sys.stdin)['access_token'])")
H="Authorization: Bearer $TOKEN"
for i in $(seq 1 ${N:-100}); do
  $C -s -o /dev/null -H "$H" -XPOST "$API/orders" -d '{"item":"book","quantity":2}'
  $C -s -o /dev/null -H "$H" "$API/orders"
  $C -s -o /dev/null -H "$H" "$API/orders/$((RANDOM % 500 + 1))"        # mostly 404s
  $C -s -o /dev/null -H "$H" -XPOST "$API/orders" -d '{"item":""}'      # 422s
  $C -s -o /dev/null "$API/orders"                                      # 401s
done
echo "done (user $EMAIL)"

Try it

make certs           # creates certs/ (CA + server certificate)
make up              # API is now https://localhost:7800
$ curl --cacert certs/ca.pem -D- -o /dev/null https://localhost:7800/orders
HTTP/2 401
strict-transport-security: max-age=15552000
www-authenticate: Bearer realm="api"

$ curl -sv --cacert certs/ca.pem -o /dev/null https://localhost:7800/orders 2>&1 | grep -E "SSL connection|ALPN|subjectAltName"
* SSL connection using TLSv1.3 / AEAD-CHACHA20-POLY1305-SHA256
* ALPN: server accepted h2
*  subjectAltName: host "localhost" matched cert's "localhost"

And the failure cases, each of which must fail:

$ curl https://localhost:7800/orders                       # we don't trust the CA
curl: (60) SSL certificate problem: unable to get local issuer certificate

$ curl --cacert certs/ca.pem --tls-max 1.1 https://localhost:7800/   # old protocol
curl: (35) ... protocol version

$ curl --cacert certs/ca.pem --resolve evil.example.com:7800:127.0.0.1 https://evil.example.com:7800/
curl: (60) SSL: no alternative certificate subject name matches target host name 'evil.example.com'

$ curl http://localhost:7800/orders                        # plaintext to the TLS port
400 Bad Request

Is the database link encrypted? Ask Postgres (the psql row is my own Unix-socket session; every application connection is TLS):

app=# select a.application_name, a.client_addr, s.ssl, s.version
        from pg_stat_ssl s join pg_stat_activity a using(pid) where a.datname='app';
              | 192.168.107.6 | t | TLSv1.3
              | 192.168.107.6 | t | TLSv1.3
              | 192.168.107.6 | t | TLSv1.3
              | 192.168.107.7 | t | TLSv1.3
 psql         |               | f |

Rotate the certificate live

$ echo | openssl s_client -connect localhost:7800 -servername localhost -CAfile certs/ca.pem 2>/dev/null | openssl x509 -noout -serial
serial=2802FD58EF459A56AD7964A56E03ABA2

$ make rotate-certs          # new server certificate, same CA; the API is NOT restarted
$ sleep 6
$ echo | openssl s_client ... | openssl x509 -noout -serial
serial=7069B26B98024B392B37CDD32AAAFF81        # different: picked up live

The log shows one line: tls certificate reloaded.

Terminate TLS in the app, or in front of it?

App terminates TLS (this series) Load balancer / ingress terminates
Pros encrypted end to end, simple for small deployments central certificate management, offloads CPU, WAF features
Cons every instance needs certificates plain HTTP behind the LB unless re-encrypted

If a proxy terminates TLS for you: leave TLS_CERT_FILE empty, set TRUSTED_PROXIES (Part 10), and add HSTS at the proxy. Never trust X-Forwarded-Proto unless your own proxy sets it.

What we did not cover

  • Mutual TLS (mTLS): clients present certificates too; ideal for service-to-service calls. In Go: ClientAuth: tls.RequireAndVerifyClientCert and ClientCAs.
  • Internal observability hops (traces to Jaeger, logs to Loki, scrapes) are plain HTTP inside the private compose network. In production, use a service mesh or enable TLS on those components.
  • Automatic issuance (ACME): certbot, cert-manager, or golang.org/x/crypto/acme/autocert. Our reloader will pick up whatever they write.
  • CORS for browser clients is unrelated to TLS and isn't configured.

Debugging TLS: a pocket guide

openssl s_client -connect localhost:7800 -servername localhost -CAfile certs/ca.pem   # handshake + chain
openssl x509 -in certs/server.pem -noout -text | grep -A1 "Alternative Name"          # SANs
curl -v --cacert certs/ca.pem https://localhost:7800/                                  # protocol, cipher, ALPN

Typical errors: certificate is not valid for any names (missing SAN) · unknown authority (client lacks the CA) · certificate has expired · tls: bad certificate (client rejected us) · http: TLS handshake error ... EOF (a client spoke plain HTTP to the TLS port).

Recap

  • TLS = confidentiality + integrity + authenticity; authenticity depends on verifying the certificate chain and hostname.
  • Never disable verification. Use a local CA in development so you never need to.
  • TLS 1.2 floor, Go's default ciphers, HSTS, no HTTP listener for an API.
  • Hot-reload certificates; keep serving the old one if the new files are broken; monitor expiry.
  • sslmode=verify-full for the database; require doesn't stop a man-in-the-middle.

Exercises

  1. Set TLS_MIN_VERSION=1.3 and re-run the --tls-max 1.2 curl. What do you see?
  2. Make the API require client certificates (mTLS) on the admin port and scrape it from Prometheus with a client cert.
  3. Edit the SANs in devcert to remove localhost and watch curl fail. Read the error closely: you'll see it again in production someday.

No comments yet. Be the first to comment.