Exchange a session for a cached token, run the six checks that turn a signed string into a trustworthy statement, and then answer the question identity never answers — may this caller have this record?
Learning objectives
Cache short-lived tokens safely and explain why they are renewed early.
Fetch and cache public keys from a JWKS endpoint keyed by kid.
Implement six verification checks and name the attack each one stops.
Bind a token's actor claim to the mTLS identity of the connection carrying it.
Enforce a required scope per route and check that a record belongs to the token's subject.
What changes in each service
Both services gain the same two responsibilities:
Responsibility
Go file
TypeScript file
Ask the auth server for a token and cache it
token.go
tokens.ts
Check a token that arrived on an incoming call
token.go
tokens.ts
Then each service's existing file wires them into the handler and the polling loop.
By the end you will run three programs and watch one log line contain a service name that came from a certificate next to a user name that came from a token.
How to read this lesson
There are two halves. The first half asks for a token and caches it, and it is ordinary HTTP code with three interesting decisions. The second half verifies a token, and that is where the security actually lives. If you only have the attention for one of them today, read the second half properly.
Each half is shown first in pieces, with the reasoning, and then as a complete file you can copy. The complete file contains nothing you have not already read in the pieces above it.
Go: ask for a token
Create token.go next to main.go. Same package, new file — Go merges every .go file in a folder into one package, so nothing needs importing between them.
The cache
token.goGo
type entry struct { token string expires time.Time}// tokenSource swaps a user session for an audience-scoped token, and caches// the result, keyed by every input that changes what the token says.type tokenSource struct { client *http.Client mu sync.Mutex cache map[string]entry}func newTokenSource(client *http.Client) *tokenSource { return &tokenSource{client: client, cache: map[string]entry{}}}func (ts *tokenSource) get(session, audience, scope string) (string, error) { ts.mu.Lock() defer ts.mu.Unlock() // Key on everything that changes what the token says. Keying on audience // alone would hand one user's token to a request made for another, which // is exactly the bug a BFF serving many users would hit. key := session + "|" + audience + "|" + scope if e, ok := ts.cache[key]; ok && time.Now().Before(e.expires) { return e.token, nil } resp, err := ts.client.PostForm(authTokenURL, url.Values{ "grant_type": {grantTokenExchange}, "subject_token": {session}, "subject_token_type": {accessTokenType}, "audience": {audience}, "scope": {scope}, }) if err != nil { return "", err } defer resp.Body.Close() // Bound the read. io.ReadAll on a response body is unbounded, so a // hostile or broken peer can stream until you run out of memory. body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) if err != nil { return "", err } if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("auth server said %s: %s", resp.Status, strings.TrimSpace(string(body))) } var out struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` } if err := json.Unmarshal(body, &out); err != nil { return "", err } // Renew early, so a token never expires mid-flight. ttl := time.Duration(out.ExpiresIn)*time.Second - 10*time.Second ts.cache[key] = entry{out.AccessToken, time.Now().Add(ttl)} return out.AccessToken, nil}
Three design decisions in that function are worth stating out loud.
Why cache at all? Tokens live sixty seconds. Without a cache, every single outbound call would hit the auth server first, doubling your traffic and making the auth server a bottleneck that every request depends on.
Why renew ten seconds early?expires_in is the token's TTL, short for time to live: how many seconds it stays valid. Renewing at the last moment means a token can expire while in flight. Without that margin you get rare, maddening 401 responses that never reproduce on demand, because they depend on the request landing in the last few hundred milliseconds of a token's life.
Why the mutex? A mutex is a lock that lets only one goroutine into a piece of code at a time; Lock claims it and Unlock releases it. Without it, several goroutines could check the cache at the same moment, all find it empty, and all call the auth server with identical requests. One lock turns that stampede into one call that the others wait for.
Sending it
Once you have the token, attaching it is one line:
token.go (for reference)Go
req.Header.Set("Authorization", "Bearer "+token)
Bearer means "whoever bears this token gets the access". It is the standard prefix, and the single space after it matters.
Go: verify a token
This is where the security actually lives. Before any code, here are the six checks in plain words, because the code is much easier to read once you know what it is trying to do.
A token arrives. You are holding a string somebody handed you. Six questions decide whether to believe it:
#
The question
The attack it stops
1
Is this a header profile I understand, signed the way I expect?
An unsupported critical extension or a token that says "I am not signed at all"
2
Is the signature genuine?
Somebody edited the claims
3
Did my auth server make this?
A token from a different issuer entirely
4
Was this made for me?
A token meant for another service, replayed here
5
Is it still valid?
A token stolen last month
6
Does it name the caller I am actually talking to?
A token stolen from one service, used by another
Checks 1 to 3 ask is this token real. Checks 4 to 6 ask is this token for this situation. Both halves are necessary, and most real-world token bugs are a missing check from the second half.
The order is deliberate
Cheap checks first, expensive checks later, and never trust anything from the token until its signature has been verified. Reading a claim before check 2 means acting on data an attacker chose.
There is a seventh thing to check that is not on that list, because it is not a question about meaning — it is a question about shape.
A verified signature tells you the claims arrived unaltered. It does not tell you they are present, or that they are the right type. An attacker who controls a legitimately-signed token from somewhere, or a bug in whatever minted it, can produce a token with exp missing entirely.
The bug this prevents
In TypeScript, a missing exp makes claims.exp undefined, and Date.now() / 1000 > undefined evaluates to false — so the expiry check passes and the token never expires. A type annotation does not save you: types are erased before the code runs. Validate the shape at runtime, before any semantic check.
Go is luckier here by accident: a missing exp unmarshals to 0, which is 1970, which is expired. Do not rely on that. Both implementations below check that every required claim is present before deciding what it means.
Fetch the public key
token.goGo
// verifier checks tokens presented to us. It holds only public keys.type verifier struct { client *http.Client audience string // our own identity; tokens minted for anyone else are refused mu sync.Mutex keys map[string]*ecdsa.PublicKey}func newVerifier(client *http.Client, audience string) *verifier { return &verifier{client: client, audience: audience, keys: map[string]*ecdsa.PublicKey{}}}func (v *verifier) key(kid string) (*ecdsa.PublicKey, error) { v.mu.Lock() defer v.mu.Unlock() if k, ok := v.keys[kid]; ok { return k, nil } resp, err := v.client.Get(authJWKSURL) if err != nil { return nil, err } defer resp.Body.Close() var doc struct { Keys []struct{ Kid, Crv, X, Y string } `json:"keys"` } if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { return nil, err } for _, k := range doc.Keys { if k.Crv != "P-256" { continue } x, err1 := b64.DecodeString(k.X) y, err2 := b64.DecodeString(k.Y) if err1 != nil || err2 != nil { continue } v.keys[k.Kid] = &ecdsa.PublicKey{ Curve: elliptic.P256(), X: new(big.Int).SetBytes(x), Y: new(big.Int).SetBytes(y), } } if k, ok := v.keys[kid]; ok { return k, nil } return nil, fmt.Errorf("no key %q in jwks", kid)}
Keys are cached, and an unrecognized kid triggers a refetch.
That refetch is what makes rotation possible. It does not make rotation work on its own: the auth server must also publish the old key alongside the new one for longer than a token lifetime, which Lesson 9 covers. Discovery and overlap are two separate things, and only one of them is in this function.
RFC 7515 calls the crit header a promise that the token uses an extension the verifier must understand. This course's closed token profile implements no extensions, so the safe supported set is empty: any token containing crit is rejected. A future implementation may accept particular names only after implementing and testing their defined behavior.
The six checks
token.goGo
// verify runs every check that matters, in order. expectedActor is the peer's// certificate common name, so the last check binds the token to the connection// it arrived on.func (v *verifier) verify(bearer, expectedActor string) (*claims, error) { token := strings.TrimPrefix(bearer, "Bearer ") if token == "" || token == bearer { return nil, fmt.Errorf("missing bearer token") } parts := strings.Split(token, ".") if len(parts) != 3 { return nil, fmt.Errorf("malformed token") } var head struct { Alg string `json:"alg"` Kid string `json:"kid"` Crit json.RawMessage `json:"crit"` } raw, err := b64.DecodeString(parts[0]) if err != nil || json.Unmarshal(raw, &head) != nil { return nil, fmt.Errorf("malformed header") } // CHECK 1 -- pin the algorithm and reject critical extensions. This closed // teaching profile implements none. if head.Alg != "ES256" { return nil, fmt.Errorf("unexpected alg %q", head.Alg) } if len(head.Crit) != 0 { return nil, fmt.Errorf("unsupported critical header") } if head.Kid == "" { return nil, fmt.Errorf("header has no kid") } pub, err := v.key(head.Kid) if err != nil { return nil, err } // CHECK 2 -- the signature. sig, err := b64.DecodeString(parts[2]) if err != nil || len(sig) != 64 { return nil, fmt.Errorf("malformed signature") } digest := sha256.Sum256([]byte(parts[0] + "." + parts[1])) r := new(big.Int).SetBytes(sig[:32]) s := new(big.Int).SetBytes(sig[32:]) if !ecdsa.Verify(pub, digest[:], r, s) { return nil, fmt.Errorf("bad signature") } payload, err := b64.DecodeString(parts[1]) if err != nil { return nil, fmt.Errorf("malformed payload") } var c claims if err := json.Unmarshal(payload, &c); err != nil { return nil, err } // Shape before semantics: never let a missing claim read as a valid one. switch { case c.Iss == "" || c.Aud == "" || c.Sub == "" || c.Scope == "" || c.Act.Sub == "": return nil, fmt.Errorf("token is missing a required claim") case c.Exp == 0: return nil, fmt.Errorf("token has no exp claim") } switch { case c.Iss != tokenIssuer: // CHECK 3 -- who issued it return nil, fmt.Errorf("issuer %q not trusted", c.Iss) case c.Aud != v.audience: // CHECK 4 -- is it for us return nil, fmt.Errorf("token is for %q, not us", c.Aud) case !time.Now().Before(time.Unix(c.Exp, 0)): // CHECK 5 -- still valid return nil, fmt.Errorf("token expired") case c.Act.Sub != expectedActor: // CHECK 6 -- does it match the peer return nil, fmt.Errorf("actor %q does not match peer %q", c.Act.Sub, expectedActor) } return &c, nil}
What each check stops
#
Check
Without it
1
alg is exactly ES256, and no unsupported crit header is present
A token chooses an unsafe algorithm or requires semantics this verifier does not implement
2
The signature verifies
Anyone edits the claims to say whatever they want
3
iss is our auth server
Tokens from some other issuer are accepted here
4
aud is us
A token meant for another service works against us
5
exp has not passed
A token stolen last year still works
6
act.sub equals the peer's name
A stolen token works over anyone's connection
Check 1 is a famous real vulnerability
Old JWT libraries read alg from the token and trusted it. Send {"alg":"none"} and they accepted an unsigned token as valid. Always pin the algorithm yourself. Also reject a crit extension you do not implement: accepting it means claiming to understand rules you never ran.
Check 6 is the one people forget
It is what fuses your two layers into one proof. Without it you have "a valid certificate" and "a valid token" that never have to be about the same caller, and a token stolen from one service can be replayed over another service's connection.
The complete token.go
token.goGo
package main// Token-exchange client and JWT verifier for the Go service.import ( "crypto/ecdsa" "crypto/elliptic" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "io" "math/big" "net/http" "net/url" "strings" "sync" "time")const ( authTokenURL = "https://127.0.0.1:8445/token" authJWKSURL = "https://127.0.0.1:8445/jwks" tokenIssuer = "https://auth-server.internal" grantTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" accessTokenType = "urn:ietf:params:oauth:token-type:access_token" // Nobody should be sending us a megabyte of JSON. maxResponseBytes = 64 << 10)var b64 = base64.RawURLEncodingtype claims struct { Iss string `json:"iss"` Aud string `json:"aud"` Sub string `json:"sub"` Scope string `json:"scope"` Exp int64 `json:"exp"` Act struct { Sub string `json:"sub"` } `json:"act"`}// ---------------------------------------------------------------- requestingtype entry struct { token string expires time.Time}type tokenSource struct { client *http.Client mu sync.Mutex cache map[string]entry}func newTokenSource(client *http.Client) *tokenSource { return &tokenSource{client: client, cache: map[string]entry{}}}func (ts *tokenSource) get(session, audience, scope string) (string, error) { ts.mu.Lock() defer ts.mu.Unlock() // Key on everything that changes what the token says. Keying on audience // alone would hand one user's token to a request made for another, which // is exactly the bug a BFF serving many users would hit. key := session + "|" + audience + "|" + scope if e, ok := ts.cache[key]; ok && time.Now().Before(e.expires) { return e.token, nil } resp, err := ts.client.PostForm(authTokenURL, url.Values{ "grant_type": {grantTokenExchange}, "subject_token": {session}, "subject_token_type": {accessTokenType}, "audience": {audience}, "scope": {scope}, }) if err != nil { return "", err } defer resp.Body.Close() // Bound the read. io.ReadAll on a response body is unbounded, so a // hostile or broken peer can stream until you run out of memory. body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) if err != nil { return "", err } if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("auth server said %s: %s", resp.Status, strings.TrimSpace(string(body))) } var out struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` } if err := json.Unmarshal(body, &out); err != nil { return "", err } // Renew early, so a token never expires mid-flight. ttl := time.Duration(out.ExpiresIn)*time.Second - 10*time.Second ts.cache[key] = entry{out.AccessToken, time.Now().Add(ttl)} return out.AccessToken, nil}// ----------------------------------------------------------------- verifying// verifier checks tokens presented to us. It holds only public keys.type verifier struct { client *http.Client audience string mu sync.Mutex keys map[string]*ecdsa.PublicKey}func newVerifier(client *http.Client, audience string) *verifier { return &verifier{client: client, audience: audience, keys: map[string]*ecdsa.PublicKey{}}}func (v *verifier) key(kid string) (*ecdsa.PublicKey, error) { v.mu.Lock() defer v.mu.Unlock() if k, ok := v.keys[kid]; ok { return k, nil } resp, err := v.client.Get(authJWKSURL) if err != nil { return nil, err } defer resp.Body.Close() var doc struct { Keys []struct{ Kid, Crv, X, Y string } `json:"keys"` } if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { return nil, err } for _, k := range doc.Keys { if k.Crv != "P-256" { continue } x, err1 := b64.DecodeString(k.X) y, err2 := b64.DecodeString(k.Y) if err1 != nil || err2 != nil { continue } v.keys[k.Kid] = &ecdsa.PublicKey{ Curve: elliptic.P256(), X: new(big.Int).SetBytes(x), Y: new(big.Int).SetBytes(y), } } if k, ok := v.keys[kid]; ok { return k, nil } return nil, fmt.Errorf("no key %q in jwks", kid)}func (v *verifier) verify(bearer, expectedActor string) (*claims, error) { token := strings.TrimPrefix(bearer, "Bearer ") if token == "" || token == bearer { return nil, fmt.Errorf("missing bearer token") } parts := strings.Split(token, ".") if len(parts) != 3 { return nil, fmt.Errorf("malformed token") } var head struct { Alg string `json:"alg"` Kid string `json:"kid"` Crit json.RawMessage `json:"crit"` } raw, err := b64.DecodeString(parts[0]) if err != nil || json.Unmarshal(raw, &head) != nil { return nil, fmt.Errorf("malformed header") } // Pinned, so a token claiming alg "none" or a symmetric alg is refused. if head.Alg != "ES256" { return nil, fmt.Errorf("unexpected alg %q", head.Alg) } // This closed profile supports no JWS critical-header extensions. if len(head.Crit) != 0 { return nil, fmt.Errorf("unsupported critical header") } if head.Kid == "" { return nil, fmt.Errorf("header has no kid") } pub, err := v.key(head.Kid) if err != nil { return nil, err } sig, err := b64.DecodeString(parts[2]) if err != nil || len(sig) != 64 { return nil, fmt.Errorf("malformed signature") } digest := sha256.Sum256([]byte(parts[0] + "." + parts[1])) r := new(big.Int).SetBytes(sig[:32]) s := new(big.Int).SetBytes(sig[32:]) if !ecdsa.Verify(pub, digest[:], r, s) { return nil, fmt.Errorf("bad signature") } payload, err := b64.DecodeString(parts[1]) if err != nil { return nil, fmt.Errorf("malformed payload") } var c claims if err := json.Unmarshal(payload, &c); err != nil { return nil, err } // Validate the shape before the semantics. Go gives us some of this for // free -- a missing exp unmarshals to 0, which is in the past -- but // relying on a zero value to mean "invalid" is luck, not a check. switch { case c.Iss == "" || c.Aud == "" || c.Sub == "" || c.Scope == "" || c.Act.Sub == "": return nil, fmt.Errorf("token is missing a required claim") case c.Exp == 0: return nil, fmt.Errorf("token has no exp claim") } switch { case c.Iss != tokenIssuer: return nil, fmt.Errorf("issuer %q not trusted", c.Iss) case c.Aud != v.audience: return nil, fmt.Errorf("token is for %q, not us", c.Aud) case !time.Now().Before(time.Unix(c.Exp, 0)): return nil, fmt.Errorf("token expired") case c.Act.Sub != expectedActor: return nil, fmt.Errorf("actor %q does not match peer %q", c.Act.Sub, expectedActor) } return &c, nil}
Go: wire it into the service
main.go now holds shared dependencies in a small struct instead of using package-level functions.
main.goGo
type app struct { client *http.Client tokens *tokenSource verifier *verifier}// The data this service actually protects. In a real system this is a// database; two rows are enough to show what authorization has to decide.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"},}// hasScope reports whether a space-separated scope string contains one scope.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: a load balancer asking// whether this process is alive is not acting on anybody's behalf.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 returns one order, and only to the user it belongs to.//// Three separate decisions happen here, and it is worth naming them://// authentication -- which service, and which user, proved by layer 1 and 2// scope -- is this token permitted to do this KIND of thing// ownership -- may this particular user see this particular recordfunc (a *app) showOrder(w http.ResponseWriter, r *http.Request) { // Layer 1: enforced by the handshake. Verified identity. caller := r.TLS.PeerCertificates[0].Subject.CommonName // Layer 2: which user, and does the token's actor match the wire identity? c, err := a.verifier.verify(r.Header.Get("Authorization"), caller) if err != nil { log.Printf("<- %-13s GET %s DENIED: %v", caller, r.URL.Path, 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, part one: does this token carry the scope this route // needs? A valid token is not a permission slip for everything. if !hasScope(c.Scope, requiredScope) { log.Printf("<- %-13s GET %s DENIED: scope %q lacks %s", caller, r.URL.Path, c.Scope, requiredScope) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) _ = json.NewEncoder(w).Encode(map[string]string{"error": "insufficient_scope"}) return } id := strings.TrimPrefix(r.URL.Path, "/orders/") o, ok := orders[id] if !ok { w.WriteHeader(http.StatusNotFound) return } // Authorization, part two: does this record belong to the user the token // names? Nothing above answered this. The caller proved which service it // is and which user it acts for; neither says this user may see THIS row. if o.Owner != c.Sub { log.Printf("<- %-13s GET %s DENIED: order belongs to %s, not %s", caller, r.URL.Path, o.Owner, c.Sub) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) _ = json.NewEncoder(w).Encode(map[string]string{"error": "forbidden"}) return } log.Printf("<- %-13s 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)}
Notice caller is passed into verify. That single argument is check 6, wiring the two layers together.
The complete main.go
main.goGo
// Go side of the demo.//// Two layers guard every call://// mTLS proves WHICH SERVICE is on the other end of the socket// token proves WHICH USER the call is for, and names the calling// service in `act`, so the two layers must agreepackage mainimport ( "crypto/tls" "crypto/x509" "encoding/json" "fmt" "io" "log" "net/http" "os" "strings" "time")const ( port = "8443" caFile = "certs/ca.crt" certFile = "certs/go.crt" keyFile = "certs/go.key" // Our own audience. Tokens minted for anyone else are refused. myAudience = "https://go-server.internal" // The peer we call, and the audience its tokens must carry. peerURL = "https://127.0.0.1:8444/orders/1001" peerAudience = "https://ts-server.internal" // 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")func tlsConfig() (*tls.Config, error) { cert, err := tls.LoadX509KeyPair(certFile, keyFile) if err != nil { return nil, err } pem, err := os.ReadFile(caFile) if err != nil { return nil, err } pool := x509.NewCertPool() if !pool.AppendCertsFromPEM(pem) { return nil, fmt.Errorf("no certificates found in %s", caFile) } return &tls.Config{ Certificates: []tls.Certificate{cert}, RootCAs: pool, ClientCAs: pool, ClientAuth: tls.RequireAndVerifyClientCert, MinVersion: tls.VersionTLS13, }, nil}type app struct { client *http.Client tokens *tokenSource verifier *verifier}// The data this service actually protects. In a real system this is a// database; two rows are enough to show what authorization has to decide.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"},}// hasScope reports whether a space-separated scope string contains one scope.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: a load balancer asking// whether this process is alive is not acting on anybody's behalf.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 returns one order, and only to the user it belongs to.//// Three separate decisions happen here, and it is worth naming them://// authentication -- which service, and which user, proved by layer 1 and 2// scope -- is this token permitted to do this KIND of thing// ownership -- may this particular user see this particular recordfunc (a *app) showOrder(w http.ResponseWriter, r *http.Request) { // Layer 1: enforced by the handshake. Verified identity. caller := r.TLS.PeerCertificates[0].Subject.CommonName // Layer 2: which user, and does the token's actor match the wire identity? c, err := a.verifier.verify(r.Header.Get("Authorization"), caller) if err != nil { log.Printf("<- %-13s GET %s DENIED: %v", caller, r.URL.Path, 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, part one: does this token carry the scope this route // needs? A valid token is not a permission slip for everything. if !hasScope(c.Scope, requiredScope) { log.Printf("<- %-13s GET %s DENIED: scope %q lacks %s", caller, r.URL.Path, c.Scope, requiredScope) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) _ = json.NewEncoder(w).Encode(map[string]string{"error": "insufficient_scope"}) return } id := strings.TrimPrefix(r.URL.Path, "/orders/") o, ok := orders[id] if !ok { w.WriteHeader(http.StatusNotFound) return } // Authorization, part two: does this record belong to the user the token // names? Nothing above answered this. The caller proved which service it // is and which user it acts for; neither says this user may see THIS row. if o.Owner != c.Sub { log.Printf("<- %-13s GET %s DENIED: order belongs to %s, not %s", caller, r.URL.Path, o.Owner, c.Sub) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) _ = json.NewEncoder(w).Encode(map[string]string{"error": "forbidden"}) return } log.Printf("<- %-13s 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 scoped to the peer, then calls it.func (a *app) poll() { for { time.Sleep(3 * time.Second) token, err := a.tokens.get(mySession, peerAudience, 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.client.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) cfg, err := tlsConfig() if err != nil { log.Fatalf("tls setup failed (run ./gen-certs.sh first): %v", err) } client := &http.Client{ Timeout: 3 * time.Second, Transport: &http.Transport{TLSClientConfig: cfg.Clone()}, } a := &app{ client: client, tokens: newTokenSource(client), verifier: newVerifier(client, myAudience), } 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: cfg, } fmt.Printf("The Go Server is running on port %s (https, mTLS + token required)\n", port) go a.poll() log.Fatal(srv.ListenAndServeTLS("", ""))}
TypeScript: the same, with two Node quirks
Node can do all of this with node:crypto. No libraries.
Import a JWK straight into a key object. You do not have to reconstruct curve points by hand the way the Go code does:
the shortcutTypeScript
createPublicKey({ key: jwk, format: "jwk" })
Tell Node how the signature is laid out. This one costs people hours:
JWS carries an ECDSA signature as raw r followed by s. Node's default expectation is DER, a different layout. Without dsaEncoding: "ieee-p1363" Node rejects a perfectly valid signature and tells you only bad signature.
That is the same 64-byte layout the auth server produced with FillBytes in Lesson 9, seen from the other side.
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";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: Buffer; cert: Buffer; key: Buffer };/** 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, 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. * * Tokens live about 60s, so without a cache every call would hit the auth * server on every request. The cache key includes the session and the scope, * not just the audience, so a service holding many users' sessions can never * reuse the wrong one. */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; // Node imports a JWK directly -- no manual curve maths needed. 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. * * expectedActor is the peer's certificate common name. The last check binds * the token to the mTLS connection it arrived on, so a leaked token is * useless to anyone else. */ 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. 'ieee-p1363' is the // name for that layout; without it Node expects DER and rejects a valid // signature. 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.//// Two layers guard every call:// 1. mTLS -- proves WHICH SERVICE is on the other end of the socket// 2. token -- proves WHICH USER the call is for, and names the calling// service in `act` so the two layers must agreeimport { createServer, request } from "node:https";import { readFileSync } from "node:fs";import type { TLSSocket } from "node:tls";import { TokenError, TokenSource, Verifier } from "./tokens.ts";const PORT = 8444;const ca = readFileSync("certs/ca.crt");const cert = readFileSync("certs/ts.crt");const key = readFileSync("certs/ts.key");const tls = { ca, cert, key };// Our own audience. Tokens minted for anyone else are refused.const MY_AUDIENCE = "https://ts-server.internal";// The peer we call, and the audience its tokens must carry.const PEER_URL = "https://127.0.0.1:8443/orders/1002";const PEER_AUDIENCE = "https://go-server.internal";// The user session this service holds server-side.const MY_SESSION = "sess-ts-2c19";const MY_SCOPE = "orders:read";// What this service demands of anyone calling its order route.const REQUIRED_SCOPE = "orders:read";// The data this service protects. A database in a real system.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;const tokens = new TokenSource(tls);const verifier = new Verifier(tls, MY_AUDIENCE);function log(message: string) { console.log(new Date().toTimeString().slice(0, 8), message);}function httpsGet(url: string, token: string): Promise<{ status: number; body: string }> { return new Promise((resolve, reject) => { const req = request( url, { ...tls, 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(); });}const server = createServer( { cert, key, ca, requestCert: true, rejectUnauthorized: true, minVersion: "TLSv1.3" }, async (req, res) => { // Liveness probe: no token, no user. A load balancer asking whether this // process is alive is not acting on anybody's behalf. 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 1: already enforced by the handshake. const caller = (req.socket as TLSSocket).getPeerCertificate().subject.CN; // Layer 2: which user, and does the token's actor match the wire identity? 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(13)} GET ${req.url} DENIED: ${why}`); res.writeHead(401, { "Content-Type": "application/json", "WWW-Authenticate": 'Bearer error="invalid_token"', }); res.end(JSON.stringify({ error: "invalid_token" })); return; } // Authorization, part one: does this token carry the scope this route // needs? A valid token is not a permission slip for everything. if (!hasScope(claims.scope, REQUIRED_SCOPE)) { log(`<- ${caller.padEnd(13)} GET ${req.url} 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; } // Authorization, part two: does this record belong to the user the token // names? Nothing above answered that question. if (order.owner !== claims.sub) { log(`<- ${caller.padEnd(13)} GET ${req.url} 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(13)} 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}`);});// Exchange the user session for a token scoped to 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, PEER_AUDIENCE, 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} (https, mTLS + token required)`, ); poll();});
Run all three
Three terminals, in this order:
zsh — 80×24
$go run ./authserver
$
zsh — 80×24
$go run .
$
zsh — 80×24
$node server.ts
$
auth server output
The Auth Server is running on port 8445 (https, mTLS required, kid=078a5280945a539e)
16:14:46 minted act=go-server sub=user-8f3a aud=https://ts-server.internal scope=orders:read ttl=1m0s
typescript output
16:14:46 <- go-server GET /orders/1001 user=user-8f3a scope=orders:read
Read that last line carefully. go-server came from a certificate. user-8f3a came from a token. Two credentials, two independent sources, one request.
Why so few minted lines?
The cache. One token covers roughly fifty seconds of calls, so at one call every three seconds you should see a minted line about once a minute per direction, not seventeen times.
Attack your own service
Each of these should fail, and the failure message tells you which check caught it.
No token. Call with a valid certificate but no Authorization header:
with missing bearer token in the Go log. Layer 2 is genuinely independent of layer 1: a perfect certificate is not enough.
Use the order route, not /health
Every test in this section must target /orders/1001. /health returns before the token is ever examined — that is the whole point of it being a liveness probe — so pointing an attack at /health proves nothing and quietly returns 200.
Wrong audience. Mint a token aimed at https://go-server.internal and send it to the TypeScript service on 8444. Check 4 refuses it: token is for '...', not us.
Tampering. Take a valid token, change one character in the middle chunk, and send it. Check 2 refuses it: bad signature. You cannot edit claims, only read them.
Replay across connections. This is check 6, and it is the one worth understanding. Mint a token as go-server, aimed at the Go service:
The token is genuine, unexpired, and aimed at the right audience — so checks 1 to 5 all pass. It still fails:
go output
<- ts-server GET /orders/1001 DENIED: actor "go-server" does not match peer "ts-server"
Common errors
Error
Meaning
Fix
bad signature on a token you just minted
Wrong base64 variant somewhere
Use RawURLEncoding in Go and "base64url" in Node, everywhere
Node reports bad signature, Go verifies the same token fine
Node expected DER
Add dsaEncoding: "ieee-p1363"
no key "..." in jwks
The auth server restarted with a new key
Verifiers refetch on an unknown kid, so restart the caller too if it cached a stale set
token expired under load
No renewal margin, or clock drift between machines
Renew about 10s before exp, and keep clocks synchronized
actor "..." does not match peer "..."
The token and the certificate disagree
Usually a replayed token. Working as intended
invalid_target
The audience is not in the auth server's allow list
Add it to audiences in authserver/main.go
Lesson 10 checkpoint
You are ready for Lesson 11 when you can explain all of these without rereading:
why tokens are cached and why the cache expires early;
why an unknown kid triggers a JWKS refetch, and why that alone is not rotation;
what each of the six checks stops, without looking at the table;
why check 1 pins the algorithm and rejects every critical extension this verifier does not implement;
why check 6 is what fuses the certificate layer to the token layer;
why the verifier holding only public keys matters if it is compromised.
Both layers now work. But look back at Lesson 5 for a moment: those certificates are valid for ten years and sit in a folder on disk. Chapter 5 fixes that, and the fix changes how you think about credentials entirely.