gw-07 step 01 — mTLS, identity extraction, and authorization

Goal

Stand up mutual TLS between a client and the gateway, extract the client's SPIFFE identity from its certificate, enforce a per-route authorization policy, and hot-rotate the server cert with zero dropped connections.

Set up a CA and certs (smallstep step makes this 2 minutes)

step certificate create "Edge CA" ca.crt ca.key \
  --profile root-ca --no-password --insecure

# Gateway server cert (hostname in SAN):
step certificate create gateway.local gw.crt gw.key \
  --profile leaf --ca ca.crt --ca-key ca.key --no-password --insecure \
  --san gateway.local

# Client workload cert with a SPIFFE ID in the URI SAN:
step certificate create asset-service client.crt client.key \
  --profile leaf --ca ca.crt --ca-key ca.key --no-password --insecure \
  --san spiffe://netflix/studio/asset-service

Code — src/go/mtls.go

package mtls

import (
	"crypto/tls"
	"crypto/x509"
	"errors"
	"net/http"
	"os"
	"sync/atomic"
)

// certHolder enables hot rotation: GetCertificate reads an atomically
// swappable cert so new handshakes use the latest without dropping
// existing connections.
type certHolder struct{ cur atomic.Pointer[tls.Certificate] }

func (h *certHolder) get(*tls.ClientHelloInfo) (*tls.Certificate, error) {
	return h.cur.Load(), nil
}

func (h *certHolder) load(certFile, keyFile string) error {
	c, err := tls.LoadX509KeyPair(certFile, keyFile)
	if err != nil {
		return err
	}
	h.cur.Store(&c)
	return nil
}

// NewServerTLS builds a mutual-TLS config: requires + verifies a client
// cert chained to caFile, and hot-rotates the server cert.
func NewServerTLS(certFile, keyFile, caFile string) (*tls.Config, *certHolder, error) {
	caPEM, err := os.ReadFile(caFile)
	if err != nil {
		return nil, nil, err
	}
	pool := x509.NewCertPool()
	if !pool.AppendCertsFromPEM(caPEM) {
		return nil, nil, errors.New("bad CA pem")
	}
	h := &certHolder{}
	if err := h.load(certFile, keyFile); err != nil {
		return nil, nil, err
	}
	cfg := &tls.Config{
		MinVersion:     tls.VersionTLS13,
		GetCertificate: h.get,                       // hot rotation hook
		ClientAuth:     tls.RequireAndVerifyClientCert, // mTLS: demand a client cert
		ClientCAs:      pool,
		NextProtos:     []string{"h2", "http/1.1"},  // ALPN
	}
	return cfg, h, nil
}

// SpiffeID pulls the SPIFFE URI SAN from the verified client cert.
func SpiffeID(r *http.Request) (string, bool) {
	if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 {
		return "", false
	}
	for _, u := range r.TLS.PeerCertificates[0].URIs {
		if u.Scheme == "spiffe" {
			return u.String(), true
		}
	}
	return "", false
}

Code — authorization middleware (the inbound security filter)

package mtls

import "net/http"

// Policy: which SPIFFE identities may hit which path prefixes.
var policy = map[string][]string{
	"/studio/assets": {"spiffe://netflix/studio/asset-service"},
	"/healthz":       {"*"}, // open
}

func Authorize(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id, ok := SpiffeID(r) // identity from the verified cert — NOT a header
		if !ok {
			http.Error(w, "no client identity", http.StatusUnauthorized) // 401
			return
		}
		allowed := policyFor(r.URL.Path)
		if !contains(allowed, id) && !contains(allowed, "*") {
			http.Error(w, "forbidden for "+id, http.StatusForbidden) // 403
			return
		}
		next.ServeHTTP(w, r)
	})
}

Run + rotate

cfg, holder, _ := NewServerTLS("gw.crt", "gw.key", "ca.crt")
srv := &http.Server{Addr: ":8443", TLSConfig: cfg, Handler: Authorize(routes)}

// Rotation goroutine: reload the cert periodically (or on SDS push).
go func() {
	for range time.Tick(1 * time.Hour) {
		_ = holder.load("gw.crt", "gw.key") // new handshakes pick it up atomically
	}
}()
srv.ListenAndServeTLS("", "") // certs come from TLSConfig

Test it:

# With the right client cert + identity -> 200:
curl --cacert ca.crt --cert client.crt --key client.key \
     --resolve gateway.local:8443:127.0.0.1 https://gateway.local:8443/studio/assets

# Without a client cert -> handshake fails (mTLS required):
curl --cacert ca.crt https://gateway.local:8443/studio/assets   # TLS error

# Right cert, wrong route for that identity -> 403.

Tasks

  1. Build the mTLS server; confirm a valid client cert reaches /studio/assets, a missing client cert fails the handshake, and a valid-but-unauthorized identity gets 403.
  2. Hot-rotate: while wrk/curl loops against the gateway, reissue gw.crt and call holder.load; confirm new connections use the new cert and no in-flight request is dropped.
  3. Add the fail policy: make Authorize call a mock external authz that you can turn off; implement per-route fail-open vs fail-closed and demonstrate both.

Acceptance

  • mTLS enforced (no client cert ⇒ no connection); identity extracted from the cert SAN; per-route authz returns 401/403 correctly.
  • Cert rotation with zero dropped requests.
  • A working, configurable fail-open/fail-closed behavior with a clear per-route rationale.

Discussion prompts

  • Why extract identity from the verified cert and never from a request header? (A header is client-controlled; the cert is cryptographically proven. Ties to the gw-01 PROXY-protocol / gw-03 XFF trust boundary.)
  • Why does RequireAndVerifyClientCert reject at the handshake rather than returning a 401? What does that save you?
  • Each new mTLS connection pays a doubled asymmetric handshake. Connect this to gw-04: how much CPU does connection reuse save here, and how would you measure it?