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
- 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. - Hot-rotate: while
wrk/curlloops against the gateway, reissuegw.crtand callholder.load; confirm new connections use the new cert and no in-flight request is dropped. - Add the fail policy: make
Authorizecall 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
RequireAndVerifyClientCertreject 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?