Point all four programs at the identity library, delete the certificates folder for good, then discover why a rotated certificate may never have been presented at all.
Learning objectives
Rewire three Go programs and one Node service onto socket-issued identities.
Move the token layer onto one naming scheme shared with certificates.
Demonstrate that connection reuse means a short certificate does not give a short session.
Bound a live connection and a resumable TLS session separately from the credential.
What changes
Every program moves off disk certificates and onto the workload API.
Program
Before
After
Go service
certs/go.crt + .key
SVID from the socket, refreshed every 20s
TypeScript service
certs/ts.crt + .key
SVID from the socket, refreshed every 20s
Auth server
certs/auth.crt + .key, certs/token.key
SVID from the socket, signing key in memory
Two things follow, and both are forced rather than chosen.
Identities become SPIFFE IDs everywhere. An SVID has no common name, so Subject.CommonName returns an empty string. Anything that read a CN must now read the URI — including the token's act and aud claims, because check 6 compares them with the certificate identity.
Certificates and tokens end up sharing one naming scheme. In Lesson 10 the certificate said go-server while the token's audience said https://go-server.internal. Two vocabularies for one thing, which works right until somebody renames one and the match silently stops. From here there is one namespace:
one namespace
certificate URI SAN : spiffe://demo.local/service/go
token act.sub : spiffe://demo.local/service/go
token aud : spiffe://demo.local/service/typescript
Why silence is the dangerous part
A security check that quietly stops matching is worse than one that fails loudly, because the system keeps working and nobody investigates. Two naming schemes that must agree is exactly that kind of trap.
Keep the workload API from Lesson 11 running throughout, and have the library from Lesson 12 in place before you start.
Keep the two languages enforcing one policy
Go applies its SPIFFE allow list inside the handshake, so it covers every route including /health. Node checks the allow list in the handler, so it is easy to write a route that returns before the check runs. When you wire the TypeScript service, put the identity check above the route dispatch — otherwise the two services enforce different policies and only one of them is the one you documented.
Nothing works until you finish
This is one migration in three edits. Between the first and the last, go run . will not compile and node server.ts will not start, because half your programs still expect files the others no longer use. That is expected.
Step 3: rewire the Go service
token.go does not change at all. Only the values passed into it do, which is a good sign: the token layer never cared what an identity string looked like.
main.go changes in four places.
Constants. Drop caFile, certFile, keyFile, myAudience, and peerAudience. Add a refresh interval.
Delete the whole TLS config function. The spiffe package owns tlsConfig() now.
One client per destination:
main.goGo
toPeer := &http.Client{ Timeout: 3 * time.Second, Transport: &http.Transport{ TLSClientConfig: source.ClientTLS(spiffe.TSID), // Lab setting: no connection pooling, so every call handshakes and // a rotated certificate is genuinely presented. The section after // you run this explains why it is here and what production does. DisableKeepAlives: true, }, } toAuth := &http.Client{ Timeout: 3 * time.Second, Transport: &http.Transport{ TLSClientConfig: source.ClientTLS(spiffe.AuthID), DisableKeepAlives: true, }, }
This looks like a small detail. It is not. Each client asserts who must be on the other end. Point the auth client at the wrong service and the handshake fails before a byte of the request is sent. That single change stops a DNS hijack, a configuration typo, and one service impersonating another.
Where Lesson 10 read .Subject.CommonName, we now read the URI. Same principle, same guarantee: the caller does not get to say who it is.
The complete main.go
main.goGo
// Go side. Both layers at once, both expiring in sixty seconds.//// layer 1 a SPIFFE certificate -> proves WHICH SERVICE is calling// layer 2 an exchanged token -> proves WHICH USER it is for//// Both layers speak the same names now: the certificate says// spiffe://demo.local/service/go and the token's `act` claim says the same.package mainimport ( "encoding/json" "fmt" "io" "log" "net/http" "strings" "time" "secure-services/spiffe")const ( port = "8443" peerURL = "https://127.0.0.1:8444/orders/1001" refetch = 20 * time.Second // SVIDs live 60s; renew well before that // The user session this service holds server-side, exactly as a BFF would // hold one per logged-in browser session. mySession = "sess-go-8f3a" myScope = "orders:read" // What this service demands of anyone calling its order route. requiredScope = "orders:read")type app struct { peer *http.Client // talks to the TypeScript service tokens *tokenSource verifier *verifier}// The data this service protects.type order struct { ID string `json:"id"` Owner string `json:"owner"` Item string `json:"item"`}var orders = map[string]order{ "1001": {ID: "1001", Owner: "user-8f3a", Item: "mechanical keyboard"}, "1002": {ID: "1002", Owner: "user-2c19", Item: "27-inch monitor"},}func hasScope(granted, want string) bool { for _, s := range strings.Fields(granted) { if s == want { return true } } return false}// health is a liveness probe. No token and no user.func (a *app) health(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})}// showOrder accepts a call only when both layers agree AND the caller is// authorized: right scope, right owner.func (a *app) showOrder(w http.ResponseWriter, r *http.Request) { // Layer 1: enforced by the handshake. This is a proven identity. caller, err := spiffe.PeerID(r.TLS.PeerCertificates[0]) if err != nil { log.Printf("<- DENIED: %v", err) w.WriteHeader(http.StatusForbidden) return } // Layer 2: which user, and does the token name the same caller? c, err := a.verifier.verify(r.Header.Get("Authorization"), caller) if err != nil { log.Printf("<- %-40s DENIED: %v", caller, err) w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) _ = json.NewEncoder(w).Encode(map[string]string{"error": "invalid_token"}) return } // Authorization: the right kind of permission, then the right record. if !hasScope(c.Scope, requiredScope) { log.Printf("<- %-40s DENIED: scope %q lacks %s", caller, c.Scope, requiredScope) w.WriteHeader(http.StatusForbidden) _ = json.NewEncoder(w).Encode(map[string]string{"error": "insufficient_scope"}) return } o, ok := orders[strings.TrimPrefix(r.URL.Path, "/orders/")] if !ok { w.WriteHeader(http.StatusNotFound) return } if o.Owner != c.Sub { log.Printf("<- %-40s DENIED: order belongs to %s, not %s", caller, o.Owner, c.Sub) w.WriteHeader(http.StatusForbidden) _ = json.NewEncoder(w).Encode(map[string]string{"error": "forbidden"}) return } log.Printf("<- %-40s GET %s user=%s scope=%s", caller, r.URL.Path, c.Sub, c.Scope) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(o)}// poll exchanges the user session for a token aimed at the peer, then calls it.func (a *app) poll() { for { time.Sleep(3 * time.Second) // The audience is the peer's SPIFFE ID, so this token works against // that service and no other. token, err := a.tokens.get(mySession, spiffe.TSID, myScope) if err != nil { log.Printf("--> auth exchange failed: %v", err) continue } req, err := http.NewRequest(http.MethodGet, peerURL, nil) if err != nil { log.Printf("--> ts %v", err) continue } req.Header.Set("Authorization", "Bearer "+token) resp, err := a.peer.Do(req) if err != nil { log.Printf("--> ts unreachable: %v", err) continue } body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) resp.Body.Close() if err != nil { log.Printf("--> ts reply unreadable: %v", err) continue } log.Printf("--> ts %s %s", resp.Status, strings.TrimSpace(string(body))) }}func main() { log.SetFlags(log.Ltime) source, err := spiffe.New() if err != nil { log.Fatalf("no identity (is the workload api running?): %v", err) } // One client per destination, because each expects a different peer // identity. Talking to the wrong service fails during the handshake. toPeer := &http.Client{ Timeout: 3 * time.Second, Transport: &http.Transport{ TLSClientConfig: source.ClientTLS(spiffe.TSID), // Lab setting: no connection pooling, so every call handshakes and // a rotated certificate is genuinely presented. The section after // you run this explains why it is here and what production does. DisableKeepAlives: true, }, } toAuth := &http.Client{ Timeout: 3 * time.Second, Transport: &http.Transport{ TLSClientConfig: source.ClientTLS(spiffe.AuthID), DisableKeepAlives: true, }, } a := &app{ peer: toPeer, tokens: newTokenSource(toAuth), verifier: newVerifier(toAuth, spiffe.GoID), // tokens must be aimed at us } mux := http.NewServeMux() mux.HandleFunc("/health", a.health) mux.HandleFunc("/orders/", a.showOrder) srv := &http.Server{ Addr: "127.0.0.1:" + port, Handler: mux, TLSConfig: source.ServerTLS(spiffe.TSID), // only the TS service may call us } id := source.Current() fmt.Printf("The Go Server is running on port %s (identity %s)\n", port, id.ID) fmt.Printf(" svid valid until %s, renewing every %s\n", id.Expiry.Local().Format("15:04:05"), refetch) go source.KeepFresh(refetch, func(id *spiffe.Identity, err error) { if err != nil { log.Printf(" rotation failed, keeping old svid: %v", err) return } log.Printf(" rotated svid, now valid until %s", id.Expiry.Local().Format("15:04:05")) }) go a.poll() log.Fatal(srv.ListenAndServeTLS("", ""))}
Step 4: rewire the auth server
Three changes to authserver/main.go.
It gets an identity like everybody else. Delete loadSigner and tlsConfig, and replace them with:
authserver/main.goGo
source, err := spiffe.New() if err != nil { log.Fatalf("no identity (is the workload api running?): %v", err) }
Sit with this for a second
The thing that issues credentials has no permanent credential of its own. It is attested by the workload API exactly like the services are. There is no bootstrap secret anywhere in this system: no file, no environment variable, nothing to leak.
The signing key moves into memory:
authserver/main.goGo
// newSigner creates the JWT signing key in memory at startup.//// Nothing is written to disk, which is the point of this chapter and also a// real cost: a restart loses the key entirely, so every token it signed// becomes unverifiable. The lesson explains what production does instead.func newSigner() (*signer, error) { key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return nil, err } sum := sha256.Sum256(append(key.X.Bytes(), key.Y.Bytes()...)) return &signer{key: key, kid: hex.EncodeToString(sum[:8])}, nil}
The kid mechanism from Lesson 9 makes new tokens verifiable again quickly, because verifiers refetch when they meet an unfamiliar key id.
It does not recover the tokens signed before the restart. Those were signed by a key that no longer exists anywhere, so the auth server can never republish it. A verifier that already cached the old public key can keep verifying them until they expire; one that restarts, evicts its cache, or never fetched that key cannot. The failure is therefore uneven and timing-dependent, which is worse to debug than a clean outage. Discovery is not durability. A production auth server keeps signing keys in a durable store or a key management service precisely so that a restart is not a key change.
Names become SPIFFE IDs:
authserver/main.goGo
// A user session, and the one service allowed to present it.type session struct { user string presenter string}var sessions = map[string]session{ "sess-go-8f3a": {user: "user-8f3a", presenter: spiffe.GoID}, "sess-ts-2c19": {user: "user-2c19", presenter: spiffe.TSID},}// What each caller may ask for -- SPIFFE IDs now, not URLs.type grant struct { audiences map[string]bool scopes map[string]bool}var grants = map[string]grant{ spiffe.GoID: { audiences: map[string]bool{spiffe.TSID: true}, scopes: map[string]bool{"orders:read": true}, }, spiffe.TSID: { audiences: map[string]bool{spiffe.GoID: true}, scopes: map[string]bool{"orders:read": true}, },}
authserver/main.goGo
// Proven by the TLS handshake. Nothing in the request body can change it. actor, err := spiffe.PeerID(r.TLS.PeerCertificates[0]) if err != nil { fail(w, http.StatusUnauthorized, "invalid_client", err.Error()) return }
authserver/main.goGo
srv := &http.Server{ Addr: "127.0.0.1:" + port, Handler: mux, // Only the two services may ask for tokens. TLSConfig: source.ServerTLS(spiffe.GoID, spiffe.TSID), }
The complete auth server
authserver/main.goGo
// Central token service.//// It has no certificate on disk. It fetches its own SPIFFE identity from the// workload API, exactly like the services do, and generates its signing key in// memory at startup.//// Unchanged, and still the point: the actor is read from the caller's// certificate, never from the request body.package mainimport ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "log" "math/big" "net/http" "strings" "time" "secure-services/spiffe")const ( port = "8445" issuer = "https://auth-server.internal" tokenTTL = 60 * time.Second refetch = 20 * time.Second grantTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" accessTokenType = "urn:ietf:params:oauth:token-type:access_token" jwtTokenType = "urn:ietf:params:oauth:token-type:jwt")// A user session, and the one service allowed to present it.type session struct { user string presenter string}var sessions = map[string]session{ "sess-go-8f3a": {user: "user-8f3a", presenter: spiffe.GoID}, "sess-ts-2c19": {user: "user-2c19", presenter: spiffe.TSID},}// What each caller may ask for -- SPIFFE IDs now, not URLs.type grant struct { audiences map[string]bool scopes map[string]bool}var grants = map[string]grant{ spiffe.GoID: { audiences: map[string]bool{spiffe.TSID: true}, scopes: map[string]bool{"orders:read": true}, }, spiffe.TSID: { audiences: map[string]bool{spiffe.GoID: true}, scopes: map[string]bool{"orders:read": true}, },}// JWTs use base64url without padding, everywhere.var b64 = base64.RawURLEncodingtype signer struct { key *ecdsa.PrivateKey kid string // Kept and published until every token it signed has expired. Nil here, // because this process never rotates its key -- it only ever loses it. previous *ecdsa.PublicKey previousKid string}// newSigner creates the JWT signing key in memory at startup.//// Nothing is written to disk, which is the point of this chapter and also a// real cost: a restart loses the key entirely, so every token it signed// becomes unverifiable.func newSigner() (*signer, error) { key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return nil, err } sum := sha256.Sum256(append(key.X.Bytes(), key.Y.Bytes()...)) return &signer{key: key, kid: hex.EncodeToString(sum[:8])}, nil}// sign produces a compact ES256 JWS. The signature is raw r||s, as JWS requires.func (s *signer) sign(claims map[string]any) (string, error) { header, err := json.Marshal(map[string]string{ "alg": "ES256", "typ": "JWT", "kid": s.kid, }) if err != nil { return "", err } payload, err := json.Marshal(claims) if err != nil { return "", err } input := b64.EncodeToString(header) + "." + b64.EncodeToString(payload) digest := sha256.Sum256([]byte(input)) r, v, err := ecdsa.Sign(rand.Reader, s.key, digest[:]) if err != nil { return "", err } sig := make([]byte, 64) r.FillBytes(sig[:32]) v.FillBytes(sig[32:]) return input + "." + b64.EncodeToString(sig), nil}// grantScope returns the requested scopes the caller is allowed to have.func grantScope(requested string, allowed map[string]bool) (string, error) { if requested == "" { return "", errors.New("no scope requested") } granted := make([]string, 0, 4) for _, s := range strings.Fields(requested) { if !allowed[s] { return "", fmt.Errorf("scope %q not permitted", s) } granted = append(granted, s) } return strings.Join(granted, " "), nil}func fail(w http.ResponseWriter, status int, code, why string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(map[string]string{"error": code}) log.Printf(" denied: %s (%s)", code, why)}func (s *signer) exchange(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { fail(w, http.StatusMethodNotAllowed, "invalid_request", "not a POST") return } // Proven by the TLS handshake. Nothing in the request body can change it, // which is why a service cannot name someone else as the actor. actor, err := spiffe.PeerID(r.TLS.PeerCertificates[0]) if err != nil { fail(w, http.StatusUnauthorized, "invalid_client", err.Error()) return } if err = r.ParseForm(); err != nil { fail(w, http.StatusBadRequest, "invalid_request", err.Error()) return } if got := r.PostForm.Get("grant_type"); got != grantTokenExchange { fail(w, http.StatusBadRequest, "unsupported_grant_type", got) return } if got := r.PostForm.Get("subject_token_type"); got != accessTokenType { fail(w, http.StatusBadRequest, "invalid_request", "subject_token_type "+got) return } allowed, ok := grants[actor] if !ok { fail(w, http.StatusBadRequest, "unauthorized_client", actor) return } audience := r.PostForm.Get("audience") if !allowed.audiences[audience] { fail(w, http.StatusBadRequest, "invalid_target", actor+" may not target "+audience) return } sess, ok := sessions[r.PostForm.Get("subject_token")] if !ok { fail(w, http.StatusBadRequest, "invalid_request", "unknown subject_token") return } if sess.presenter != actor { fail(w, http.StatusBadRequest, "invalid_request", actor+" may not present this session") return } user := sess.user scope, scopeErr := grantScope(r.PostForm.Get("scope"), allowed.scopes) if scopeErr != nil { fail(w, http.StatusBadRequest, "invalid_scope", scopeErr.Error()) return } now := time.Now() token, err := s.sign(map[string]any{ "iss": issuer, "aud": audience, "sub": user, "act": map[string]string{"sub": actor}, "scope": scope, "iat": now.Unix(), "exp": now.Add(tokenTTL).Unix(), }) if err != nil { fail(w, http.StatusInternalServerError, "server_error", err.Error()) return } log.Printf(" minted act=%s", actor) log.Printf(" sub=%-10s aud=%s scope=%s ttl=%s", user, audience, scope, tokenTTL) w.Header().Set("Content-Type", "application/json") // RFC 6749 s5.1 requires both of these on any response carrying a token. w.Header().Set("Cache-Control", "no-store") w.Header().Set("Pragma", "no-cache") _ = json.NewEncoder(w).Encode(map[string]any{ "access_token": token, "issued_token_type": jwtTokenType, "token_type": "Bearer", "expires_in": int(tokenTTL.Seconds()), "scope": scope, })}// jwks publishes the public half, so verifiers never hold signing material.func (s *signer) jwks(w http.ResponseWriter, r *http.Request) { pad := func(n *big.Int) string { buf := make([]byte, 32) n.FillBytes(buf) return b64.EncodeToString(buf) } entry := func(kid string, pub *ecdsa.PublicKey) map[string]string { return map[string]string{ "kty": "EC", "crv": "P-256", "alg": "ES256", "use": "sig", "kid": kid, "x": pad(pub.X), "y": pad(pub.Y), } } keys := []map[string]string{entry(s.kid, &s.key.PublicKey)} if s.previous != nil { keys = append(keys, entry(s.previousKid, s.previous)) } w.Header().Set("Content-Type", "application/jwk-set+json") // Permit a compliant HTTP cache to reuse this document for five minutes. // A real rotation must coordinate this lifetime with advance publication // and old-key overlap; the teaching verifiers ignore this header. w.Header().Set("Cache-Control", "max-age=300") _ = json.NewEncoder(w).Encode(map[string]any{"keys": keys})}func main() { log.SetFlags(log.Ltime) s, err := newSigner() if err != nil { log.Fatalf("could not create signing key: %v", err) } source, err := spiffe.New() if err != nil { log.Fatalf("no identity (is the workload api running?): %v", err) } mux := http.NewServeMux() mux.HandleFunc("/token", s.exchange) mux.HandleFunc("/jwks", s.jwks) srv := &http.Server{ Addr: "127.0.0.1:" + port, Handler: mux, // Only the two services may ask for tokens. TLSConfig: source.ServerTLS(spiffe.GoID, spiffe.TSID), } fmt.Printf("The Auth Server is running on port %s (identity %s, kid=%s)\n", port, source.Current().ID, s.kid) go source.KeepFresh(refetch, func(id *spiffe.Identity, err error) { if err != nil { log.Printf(" rotation failed, keeping old svid: %v", err) return } log.Printf(" rotated svid, now valid until %s", id.Expiry.Local().Format("15:04:05")) }) log.Fatal(srv.ListenAndServeTLS("", ""))}
Step 5: rewire the TypeScript service
tokens.ts needs one change: the TLS options can no longer be a fixed value, because rotation replaces them every twenty seconds. Take a function instead.
Then in both classes, store a function and call it per request:
tokens.tsTypeScript
export class TokenSource { private cache = new Map<string, { token: string; expires: number }>(); private tls: () => TlsOpts; constructor(tls: () => TlsOpts) { this.tls = tls; } // inside get(), and inside Verifier.key(): // httpsCall(AUTH_TOKEN_URL, this.tls(), form)}
Make the same two edits to Verifier: change the field and constructor parameter to () => TlsOpts, and call this.tls() where it previously used this.tls.
Why a function and not an object
A value captured at construction time is a snapshot. Twenty seconds later that certificate is on its way to expiring and the object still holds it. Reading the identity through a function at the moment of use is the Node equivalent of Go's GetClientCertificate callback.
The complete tokens.ts
tokens.tsTypeScript
// Token-exchange client and JWT verifier for the TypeScript service.import { request } from "node:https";import { createPublicKey, verify } from "node:crypto";import type { PeerCertificate } from "node:tls";const AUTH_TOKEN_URL = "https://127.0.0.1:8445/token";const AUTH_JWKS_URL = "https://127.0.0.1:8445/jwks";const TOKEN_ISSUER = "https://auth-server.internal";const GRANT_TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange";const ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token";// Every outbound call gets a deadline and a size cap. Node gives you neither// by default: without them, a peer that accepts the connection and then goes// quiet holds a request open forever.const REQUEST_TIMEOUT_MS = 3000;const MAX_RESPONSE_BYTES = 64 * 1024;export type Claims = { iss: string; aud: string; sub: string; scope: string; exp: number; act: { sub: string };};export class TokenError extends Error {}export type TlsOpts = { ca: string | Buffer; cert: string | Buffer; key: string | Buffer; checkServerIdentity?: (host: string, cert: PeerCertificate) => Error | undefined;};/** A claim that must be present and a non-empty string. */function requireString(value: unknown, field: string): string { if (typeof value !== "string" || value.length === 0) { throw new TokenError(`claim '${field}' is missing or not a string`); } return value;}/** * Validate the claim set before trusting any field. * * The bug this prevents is quiet: if `exp` is absent, `claims.exp` is * undefined, and `Date.now() / 1000 > undefined` is false — so an unexpiring * token would sail through the expiry check. A type annotation would not have * caught it, because types do not exist at runtime. */function parseClaims(raw: unknown): Claims { if (typeof raw !== "object" || raw === null) { throw new TokenError("claims are not a JSON object"); } const c = raw as Record<string, unknown>; if (typeof c.exp !== "number" || !Number.isFinite(c.exp)) { throw new TokenError("claim 'exp' is missing or not a number"); } if (typeof c.act !== "object" || c.act === null) { throw new TokenError("claim 'act' is missing"); } return { iss: requireString(c.iss, "iss"), aud: requireString(c.aud, "aud"), sub: requireString(c.sub, "sub"), scope: requireString(c.scope, "scope"), exp: c.exp, act: { sub: requireString((c.act as Record<string, unknown>).sub, "act.sub") }, };}// Base64url is base64 with two characters swapped and the padding removed.function b64url(segment: string): Buffer { return Buffer.from(segment, "base64url");}function httpsCall( url: string, tls: TlsOpts, body?: string,): Promise<{ status: number; body: string }> { return new Promise((resolve, reject) => { const options = { ...tls, agent: false as const, // lab setting: no keep-alive, so every call handshakes minVersion: "TLSv1.3" as const, method: body ? "POST" : "GET", headers: body ? { "Content-Type": "application/x-www-form-urlencoded" } : {}, }; const req = request(url, options, (res) => { let text = ""; let received = 0; res.on("data", (chunk) => { // Bytes off the wire, not UTF-16 code units. received += chunk.length; if (received > MAX_RESPONSE_BYTES) { req.destroy(new Error("response too large")); return; } text += chunk; }); res.on("end", () => { clearTimeout(deadline); resolve({ status: res.statusCode ?? 0, body: text }); }); }); const deadline = setTimeout( () => req.destroy(new Error("request deadline exceeded")), REQUEST_TIMEOUT_MS, ); req.on("error", (error) => { clearTimeout(deadline); reject(error); }); req.end(body); });}/** * Swaps a user session for an audience-scoped token, and caches the result. * The key includes session and scope, not just audience. */export class TokenSource { private cache = new Map<string, { token: string; expires: number }>(); private tls: () => TlsOpts; constructor(tls: () => TlsOpts) { this.tls = tls; } async get(session: string, audience: string, scope: string): Promise<string> { // Key on everything that changes what the token says, not on audience // alone: a service holding sessions for many users must never hand one // user's token to a request made for another. const key = `${session}|${audience}|${scope}`; const cached = this.cache.get(key); if (cached && Date.now() < cached.expires) return cached.token; const form = new URLSearchParams({ grant_type: GRANT_TOKEN_EXCHANGE, subject_token: session, subject_token_type: ACCESS_TOKEN_TYPE, audience, scope, }).toString(); const res = await httpsCall(AUTH_TOKEN_URL, this.tls(), form); if (res.status !== 200) { throw new TokenError(`auth server said ${res.status}: ${res.body.trim()}`); } const payload = JSON.parse(res.body); // Renew early, so a token never expires mid-flight. this.cache.set(key, { token: payload.access_token, expires: Date.now() + (payload.expires_in - 10) * 1000, }); return payload.access_token; }}/** Checks tokens presented to us. Holds only public keys. */export class Verifier { private keys = new Map<string, ReturnType<typeof createPublicKey>>(); private tls: () => TlsOpts; private audience: string; // our own identity constructor(tls: () => TlsOpts, audience: string) { this.tls = tls; this.audience = audience; } private async key(kid: string) { const existing = this.keys.get(kid); if (existing) return existing; const res = await httpsCall(AUTH_JWKS_URL, this.tls()); for (const jwk of JSON.parse(res.body).keys ?? []) { if (jwk.crv !== "P-256") continue; this.keys.set(jwk.kid, createPublicKey({ key: jwk, format: "jwk" })); } const loaded = this.keys.get(kid); if (!loaded) throw new TokenError(`no key '${kid}' in jwks`); return loaded; } /** Run every check that matters, in order. */ async verify(authorization: string, expectedActor: string): Promise<Claims> { if (!authorization.startsWith("Bearer ")) { throw new TokenError("missing bearer token"); } const parts = authorization.slice("Bearer ".length).split("."); if (parts.length !== 3) throw new TokenError("malformed token"); const parsedHeader: unknown = JSON.parse(b64url(parts[0]).toString()); if ( typeof parsedHeader !== "object" || parsedHeader === null || Array.isArray(parsedHeader) ) { throw new TokenError("malformed header"); } const header = parsedHeader as Record<string, unknown>; // CHECK 1 -- pin the algorithm and reject critical extensions. This closed // profile supports none, so the presence of `crit` is always an error. if (header.alg !== "ES256") { throw new TokenError(`unexpected alg '${String(header.alg)}'`); } if ("crit" in header) { throw new TokenError("unsupported critical header"); } if (typeof header.kid !== "string" || header.kid === "") { throw new TokenError("header has no kid"); } const signature = b64url(parts[2]); if (signature.length !== 64) throw new TokenError("malformed signature"); const publicKey = await this.key(header.kid); const signedInput = Buffer.from(`${parts[0]}.${parts[1]}`); // CHECK 2 -- JWS carries the signature as raw r||s. const ok = verify( "sha256", signedInput, { key: publicKey, dsaEncoding: "ieee-p1363" }, signature, ); if (!ok) throw new TokenError("bad signature"); // TypeScript types are erased at runtime, so `as Claims` would be a lie: // JSON.parse returns whatever the attacker sent. Validate the shape first. const claims = parseClaims(JSON.parse(b64url(parts[1]).toString())); // CHECK 3 -- who issued it. if (claims.iss !== TOKEN_ISSUER) { throw new TokenError(`issuer '${claims.iss}' not trusted`); } // CHECK 4 -- is it for us. if (claims.aud !== this.audience) { throw new TokenError(`token is for '${claims.aud}', not us`); } // CHECK 5 -- still valid. RFC 7519 says a token must be rejected on or // after `exp`, so this is >= rather than >. if (Date.now() / 1000 >= claims.exp) { throw new TokenError("token expired"); } // CHECK 6 -- does it match the peer. if (claims.act?.sub !== expectedActor) { throw new TokenError( `actor '${claims.act?.sub}' does not match peer '${expectedActor}'`, ); } return claims; }}
The complete server.ts
server.tsTypeScript
// TypeScript side. Both layers at once, both expiring in sixty seconds.//// layer 1 a SPIFFE certificate -> proves WHICH SERVICE is calling// layer 2 an exchanged token -> proves WHICH USER it is forimport { createServer, request } from "node:https";import type { TLSSocket } from "node:tls";import { AUTH_ID, GO_ID, TS_ID, type Svid, checkSpiffeId, expiryOf, fetchSvid, peerId,} from "./spiffe.ts";import { TokenError, TokenSource, Verifier, type TlsOpts } from "./tokens.ts";const PORT = 8444;const PEER_URL = "https://127.0.0.1:8443/orders/1002";const REFETCH_MS = 20_000; // SVIDs live 60s; renew well before that// The user session this service holds server-side.const MY_SESSION = "sess-ts-2c19";const MY_SCOPE = "orders:read";const REQUIRED_SCOPE = "orders:read";type Order = { id: string; owner: string; item: string };const ORDERS: Record<string, Order> = { "1001": { id: "1001", owner: "user-8f3a", item: "mechanical keyboard" }, "1002": { id: "1002", owner: "user-2c19", item: "27-inch monitor" },};function hasScope(granted: string, want: string): boolean { return granted.split(/\s+/).includes(want);}// Every outbound call gets a deadline and a size cap. Node gives you neither// by default: without them, a peer that accepts the connection and then goes// quiet holds a request open forever.const REQUEST_TIMEOUT_MS = 3000;const MAX_RESPONSE_BYTES = 64 * 1024;// The identity we are currently using. Rotation just replaces this object.let current: Svid;function log(message: string) { console.log(new Date().toTimeString().slice(0, 8), message);}/** TLS options for calling one specific peer, using our current identity. */function tlsFor(expected: string): TlsOpts { return { cert: current.cert, key: current.key, ca: current.bundle, checkServerIdentity: (_host, cert) => checkSpiffeId(cert, [expected]), };}// Both of these talk to the auth server, so both assert its identity.const tokens = new TokenSource(() => tlsFor(AUTH_ID));const verifier = new Verifier(() => tlsFor(AUTH_ID), TS_ID);function httpsGet(url: string, token: string): Promise<{ status: number; body: string }> { return new Promise((resolve, reject) => { const req = request( url, { ...tlsFor(GO_ID), // Lab setting: no keep-alive, so every call handshakes. agent: false, minVersion: "TLSv1.3", headers: { Authorization: `Bearer ${token}` }, }, (res) => { let body = ""; let received = 0; res.on("data", (chunk) => { // Count BYTES off the wire. `string.length` counts UTF-16 // code units after decoding, which is a different number. received += chunk.length; if (received > MAX_RESPONSE_BYTES) { req.destroy(new Error("response too large")); return; } body += chunk; }); res.on("end", () => { clearTimeout(deadline); resolve({ status: res.statusCode ?? 0, body }); }); }, ); const deadline = setTimeout( () => req.destroy(new Error("request deadline exceeded")), REQUEST_TIMEOUT_MS, ); req.on("error", (error) => { clearTimeout(deadline); reject(error); }); req.end(); });}async function main() { try { current = await fetchSvid(); } catch (error) { console.error(`no identity (is the workload api running?): ${error}`); process.exit(1); } const server = createServer( { cert: current.cert, key: current.key, ca: current.bundle, requestCert: true, rejectUnauthorized: true, // Node verifies the signature chain minVersion: "TLSv1.3", }, async (req, res) => { // Layer 1 first, for EVERY route. Go enforces its allow list inside the // handshake, so it covers /health too; checking it here keeps the two // implementations honest about the same policy. const peer = (req.socket as TLSSocket).getPeerCertificate(); const wrongPeer = checkSpiffeId(peer, [GO_ID]); if (wrongPeer) { log(`<- DENIED: ${wrongPeer.message}`); res.writeHead(403, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "forbidden" })); return; } const caller = peerId(peer); // Tokenless liveness probe. Still mTLS-authenticated: it skips the // token, not the handshake. if (req.url === "/health") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ status: "ok" })); return; } const match = (req.url ?? "").match(/^\/orders\/([A-Za-z0-9-]+)$/); if (!match) { res.writeHead(404); res.end(); return; } // Layer 2: which user, and does the token name the same caller? let claims; try { claims = await verifier.verify(req.headers.authorization ?? "", caller); } catch (error) { const why = error instanceof TokenError ? error.message : String(error); log(`<- ${caller.padEnd(40)} DENIED: ${why}`); res.writeHead(401, { "Content-Type": "application/json", "WWW-Authenticate": 'Bearer error="invalid_token"', }); res.end(JSON.stringify({ error: "invalid_token" })); return; } // Authorization: the right kind of permission, then the right record. if (!hasScope(claims.scope, REQUIRED_SCOPE)) { log(`<- ${caller.padEnd(40)} DENIED: scope '${claims.scope}' lacks ${REQUIRED_SCOPE}`); res.writeHead(403, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "insufficient_scope" })); return; } const order = ORDERS[match[1]]; if (!order) { res.writeHead(404); res.end(); return; } if (order.owner !== claims.sub) { log(`<- ${caller.padEnd(40)} DENIED: belongs to ${order.owner}, not ${claims.sub}`); res.writeHead(403, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "forbidden" })); return; } log(`<- ${caller.padEnd(40)} GET ${req.url} user=${claims.sub} scope=${claims.scope}`); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(order)); }, ); server.on("tlsClientError", (error) => { log(`!! handshake rejected: ${error.message}`); }); // Replace the identity forever, on a timer. async function keepFresh() { while (true) { await new Promise((r) => setTimeout(r, REFETCH_MS)); try { current = await fetchSvid(); // Swaps the certificate on a running server. Open connections keep the // old one; new ones get the new one. No restart, no dropped requests. server.setSecureContext({ cert: current.cert, key: current.key, ca: current.bundle, }); log(` rotated svid, now valid until ${expiryOf(current)}`); } catch (error) { // The old identity is still valid for a while, so this is survivable. log(` rotation failed, keeping old svid: ${error}`); } } } // Exchange the user session for a token aimed at the peer, then call it. async function poll() { while (true) { await new Promise((r) => setTimeout(r, 3000)); let token: string; try { token = await tokens.get(MY_SESSION, GO_ID, MY_SCOPE); } catch (error) { log(`--> auth exchange failed: ${error}`); continue; } try { const res = await httpsGet(PEER_URL, token); log(`--> go ${res.status} ${res.body.trim()}`); } catch (error) { log(`--> go unreachable: ${error}`); } } } server.listen(PORT, "127.0.0.1", () => { console.log(`The TypeScript Server is running on port ${PORT} (identity ${TS_ID})`); console.log( ` svid valid until ${expiryOf(current)}, renewing every ${REFETCH_MS / 1000}s`, ); keepFresh(); poll(); });}main();
setSecureContext swaps the certificate on a running server. Connections already open keep their old one; new connections get the new one.
Step 6: run all four
Order matters. The workload API must be first, because everything else asks it for an identity at startup.
zsh — 80×24
$go run ./workload-api
$
zsh — 80×24
$go run ./authserver
$
zsh — 80×24
$go run .
$
zsh — 80×24
$node server.ts
$
Use those commands exactly as written. The workload API identifies each process by its command line.
<- spiffe://demo.local/service/go GET /orders/1001 user=user-8f3a scope=orders:read
└──── from the certificate ───┘ └─ from the token ─┘
Two credentials. Two sources. One request. Neither existed a minute ago.
Whether they will still exist a minute from now is a more interesting question than it looks, and the next section answers it.
Watch a rotation
Wait twenty seconds. Every process logs a line like:
go output
17:45:07 rotated svid, now valid until 17:46:07
17:45:09 --> ts 200 OK {"service":"typescript","status":"ok",...}
The certificate was replaced and traffic never stopped.
Now be suspicious of that sentence, because it is the kind of evidence that looks conclusive and proves almost nothing.
The thing that log line does not prove
A certificate is checked during the TLS handshake. It is not rechecked on every HTTP request.
That matters because both languages reuse connections by default: Go's http.Transport keeps idle connections in a pool, and Node's HTTPS agent uses keep-alive. If a connection stays open, no new handshake happens, and no new certificate is ever presented.
Your files already avoid this — DisableKeepAlives: true in Go and agent: false in Node. Now break it on purpose, so you can see what those two lines are actually buying.
Temporary experiment
Both changes below get reverted at the end of this section. Do not leave them in place.
Step 1 — count handshakes. Add this to server.ts, just above the tlsClientError handler:
One handshake carried thirty-three requests across five certificate rotations. The connection was authenticated once, with a certificate that expired long before the last request travelled over it. Traffic did not keep flowing because rotation worked. It kept flowing because nothing ever re-authenticated.
Step 4 — restore. Put DisableKeepAlives: true and agent: false back, and rerun. Now handshakes track requests one for one, and the rotation demonstration means what it claimed to mean:
Remove the counter code when you are done with it.
What you just learned about the whole chapter
Two consequences, and both matter more than the rotation itself.
The demonstration was not testing what it looked like it was testing. With pooling on, the freshly issued certificates were never presented to the peer at all. "Traffic never stopped" was evidence about connection pooling, not about identity.
A sixty-second certificate does not give you a sixty-second session. It bounds how long a stolen certificate can be used to start a new connection. It says nothing about a connection that is already open, which can outlive the credential for as long as something keeps it warm.
What production does instead
Disabling pooling is the wrong answer for a real service, because you would pay a full handshake on every request. The right answer is to keep pooling and bound how long any one connection may live, to something below your credential lifetime.
Here is the trap. The obvious knob looks like IdleConnTimeout, and it is the wrong one:
IdleConnTimeout does not do this
Go documents it as "the maximum amount of time an idle connection will remain idle before closing itself". A connection carrying a request every three seconds is never idle, so it is never closed. Go's http.Transport has no maximum-connection-age field at all.
Since the standard library will not do it for you, you have to bound it yourself. Three real options, in rough order of how often they are used:
Terminate TLS at something that supports it. Most proxies and service meshes have a maximum connection duration setting. This is the usual answer, and it is why teams running a mesh rarely think about this at all.
Wrap the dialer. Give the transport a DialContext that starts a timer per connection and closes it when the timer fires. Be careful here: closing the connection the moment the timer fires will abort whatever request is in flight on it. A usable version marks the connection unusable and closes it once it goes idle, which is more code than one line of time.AfterFunc suggests.
Recycle the transport. Build a new http.Transport on a schedule and let the old one drain. Blunt, but it is a few lines and it is easy to reason about.
Whichever you pick, write down the number and check it against your credential lifetime. A twenty-year-old connection authenticated by a sixty-second certificate is a sentence that should stop a design review.
The second trap: a new connection is not always a new handshake
Bounding connection age closes the hole you just measured. It does not close all of it, and the reason catches out people who have been doing this for years.
TLS 1.3 can resume a session. The client presents a ticket from an earlier handshake, and the server accepts it without a fresh Certificate or CertificateVerify exchange — that is the whole point, it is what makes reconnecting cheap. But it means a brand-new TCP connection can be authenticated by a handshake that happened long ago, using an identity that has since been replaced.
So the peer identity your server reads may belong to a certificate that expired several rotations back. Closing connections does not help, because the replacement connection resumes.
Whether this bites you depends entirely on configuration, and client and server settings do different jobs:
Component
Course setting
What it means
Go client
ClientSessionCache is nil
This client retains no session state, so it cannot ask to resume
Node client
agent: false
Every request gets a fresh one-use agent, so this client retains no session cache
Go server
Session tickets are enabled
A different client that keeps a ticket can ask this server to resume
Node server
Session tickets are enabled, with a 300s default timeout
A different client that keeps a ticket can ask this server to resume for up to that timeout
The request clients in this course therefore do not resume. That is why the experiment you just ran measured what it claimed to measure — but it is a property of these client settings, not a server-enforced security policy.
Worth proving to yourself
Give the Go client a ClientSessionCache, rotate its identity five times, and print r.TLS.PeerCertificates[0] on the server. You will see the first certificate still arriving on connection five, long after it was replaced. Nothing warns you. The handshake is valid; it just is not the handshake you thought you were getting.
Treat these as three separate numbers you must set, not one:
Control
What it bounds
Certificate lifetime
How long a stolen certificate can open a new connection
Maximum connection age
How long an already-authenticated session survives
Server resumption lifetime
How long a past handshake can authenticate a new connection
Enforce the third number on the server, because a compromised caller controls its own client settings. A Go server can set tls.Config.SessionTicketsDisabled: true. A Node server can set sessionTimeout below the credential lifetime, or disable tickets with SSL_OP_NO_TICKET in secureOptions. If you need resumption for performance, keep it and set a deliberate server timeout; do not mistake a client cache setting for that policy.
Client controls are still useful defense in depth: a nil Go ClientSessionCache and Node maxCachedSessions: 0 stop those particular clients from retaining resumable state. They cannot stop another client presenting a ticket that your server still accepts.
You need all three numbers. Connection age bounds a live connection; resumption lifetime bounds how long an old authenticated state can create another one. Server read and write timeouts are separate again: they bound an individual request, not either kind of authenticated session. This is why "we rotate every sixty seconds" gets said about systems where a connection opened last Tuesday is still serving traffic.
Long-lived streams and gRPC channels make this sharper still, because they are designed to stay open for hours. If you take one operational habit from this chapter, take this one: whenever somebody quotes you a credential lifetime, ask what bounds the connection.
The honest version of this chapter's claim
Short-lived certificates bound how long a leaked credential is useful for authentication. Bounding how long an authenticated connection lives is a separate control that you have to configure yourself. Long-lived streams and gRPC channels make this sharper still, because they are designed to stay open for hours.
Step 7: delete the old world
Everything runs. Now remove what you no longer need:
zsh — 80×24
$rm -rf certs gen-certs.sh
$
Restart all four programs and confirm they still work. Nothing on disk identifies any of them.
There is no certs/ line left to keep in .gitignore, and no private key anywhere in the project.
Prove the peer check works
Stop the Go service and start it under a different name so the workload API gives it the wrong identity — or simpler, temporarily change the TypeScript service's expected peer:
server.tsTypeScript
const wrongPeer = checkSpiffeId(peer, [AUTH_ID]); // TEMPORARY: was GO_ID
Restart node server.ts. The Go service's calls now come back 403:
go output
--> ts 403 Forbidden {"error":"forbidden"}
and the TypeScript log names both identities:
typescript output
<- DENIED: peer is 'spiffe://demo.local/service/go', expected one of spiffe://demo.local/service/auth
Now put the expected peer back:
server.tsTypeScript
const wrongPeer = checkSpiffeId(peer, [GO_ID]);
Restart node server.ts and confirm the 200 responses return before you continue.
Common errors
Error
Meaning
Fix
no identity (is the workload api running?)
Started out of order, or the process name does not match
Start the workload API first; use the go run commands exactly as written
no registration entry matches
Your folder is not called secure-services
Adjust the entry in workload-api/main.go to your folder name
x509: certificate is not valid for any names
Normal hostname verification is still on
Use VerifyPeerCertificate in Go, checkServerIdentity in Node
peer is [...], expected one of [...]
The wrong service called this one
Check the ServerTLS(...) allow list
certificate has expired
Rotation stopped more than sixty seconds ago
Check the workload API is still alive
package secure-services/spiffe is not in std
The module name does not match
Confirm module secure-services in go.mod
Lesson 13 checkpoint
You are ready for Lesson 14 when you can explain all of these without rereading:
why the identity is read through a callback instead of stored as a value;
why hostname verification cannot work on an SVID, and what replaces it;
exactly why InsecureSkipVerify is not a bug in ClientTLS, and when it would be;
why the auth server has no credential of its own on disk;
why one HTTP client per destination is a security control, not tidiness;
why a rotated certificate may never be presented if connections are pooled;
which controls bound a credential, a live connection, and a resumable TLS session;
what happens to already-issued certificates if the workload API stops.
Next you will step back and look at the finished system. How one request is proved twice, why an apparently redundant check earns its place, what this design costs, and how you get all of it in production without writing any of it yourself.