Part 14: TLS: encrypting the wire, hot-reloading certificates, and verifying the database
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:
- Confidentiality: nobody on the path can read the traffic.
- Integrity: nobody can modify it undetected.
- 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)
- 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). - Your operating system / browser /
curlships 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)? - 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
--insecureorInsecureSkipVerify: 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 coverslocalhost,127.0.0.1,::1,api, andpostgres. - 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. SetTLS_MIN_VERSION=1.3once 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/httpadvertisesh2via ALPN;curl -vwill showusing HTTP/2. ListenAndServeTLS("", "")with empty file names: certificates come fromTLSConfig.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
Authorizationheader 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())
}
GetCertificateruns on every TLS handshake.maybeReloadstats the files at most every 5 seconds and reloads when the modification time changed. (os.Statfollows 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.
NewReloaderloads 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.RequireAndVerifyClientCertandClientCAs. - 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-fullfor the database;requiredoesn't stop a man-in-the-middle.
Exercises
- Set
TLS_MIN_VERSION=1.3and re-run the--tls-max 1.2curl. What do you see? - Make the API require client certificates (mTLS) on the admin port and scrape it from Prometheus with a client cert.
- Edit the SANs in
devcertto removelocalhostand watchcurlfail. Read the error closely: you'll see it again in production someday.
Comments
Join the discussionNo comments yet. Be the first to comment.
Build a production-grade REST API in Go
A hands-on series: start with under 200 lines of in-memory CRUD and end with Postgres, JWT auth, rate limiting, TLS and a full observability stack.