Write a third Go program that reads the caller's identity off their certificate, exchanges a user session for a sixty-second signed token, and publishes the public key anyone needs to check it.
Learning objectives
Sign a compact ES256 JWT using only the Go standard library.
Read the acting service from the TLS handshake rather than the request body.
Publish a JWKS document and explain what a key ID is for.
Mint and decode your first token by hand with curl.
What you are building
A third program, in the same repository, listening on port 8445 and requiring mutual TLS. It offers two routes:
Route
Method
Does
/token
POST
Swaps a user session for a short-lived JWT
/jwks
GET
Publishes the public key needed to verify those JWTs
Nothing else in your repository changes in this lesson. At the end you will mint a token with curl and decode your own claims out of it.
Step 1: give the auth server a certificate
The auth server needs two different keys, and understanding why is worth a minute.
Open gen-certs.sh and add auth to the loop:
gen-certs.sh — zsh
$for name in go ts auth; do
$
Then add this to the very end of the script, after the loop but before chmod 600:
gen-certs.sh — zsh
$# 3. A separate signing key for tokens. Deliberately not the TLS key: the TLS
$# key authenticates a connection, while this one signs assertions that
$# outlive the connection and are verified by someone else entirely.
They have different jobs and different lifetimes. auth.key proves who is on the other end of a live TLS connection and never leaves that conversation. token.key signs statements that travel onward, get stored in a cache, and are checked minutes later by a service that was never part of the original conversation. Mixing the two makes it impossible to rotate one without disturbing the other.
Step 2: create the program
zsh — 80×24
$mkdir authserver
$
Create authserver/main.go. One folder with its own package main means one extra runnable program, which you start with go run ./authserver.
Your repository will end this lesson looking like this:
type signer struct { key *ecdsa.PrivateKey kid string // The key we signed with before the most recent rotation, if any. We never // sign with it again, but we keep publishing it until every token it signed // has expired. See "What rotation actually requires" below. previous *ecdsa.PublicKey previousKid string}func loadSigner(path string) (*signer, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } block, _ := pem.Decode(data) if block == nil { return nil, fmt.Errorf("no PEM block in %s", path) } parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) if err != nil { return nil, err } key, ok := parsed.(*ecdsa.PrivateKey) if !ok { return nil, fmt.Errorf("%s is not an EC key", path) } // A stable key id, so verifiers can cache the public key across restarts. sum := sha256.Sum256(append(key.X.Bytes(), key.Y.Bytes()...)) return &signer{key: key, kid: hex.EncodeToString(sum[:8])}, nil}
Two unfamiliar things here.
Unwrapping the key file.pem.Decode runs first, then ParsePKCS8PrivateKey. A .key file is base64 text wrapped in -----BEGIN PRIVATE KEY----- markers. pem.Decode unwraps it to raw bytes; ParsePKCS8PrivateKey interprets those bytes as a key. The type assertion afterward confirms we got an elliptic-curve key and not, say, an RSA one.
What is a kid? It is short for key ID: a short name for this key. It goes into the token header. When a verifier sees a kid it does not recognize, it knows to go and fetch the key set again.
That mechanism is necessary for rotation without downtime. It is not sufficient, and it is worth being precise about the difference now rather than discovering it during a rotation.
Step 4: sign a token
This is the only real cryptography you will write in this course:
authserver/main.goGo
// 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 } // The signed input is header.payload -- both base64url encoded. 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 } // JWS wants the two numbers as 32 raw bytes each, side by side. sig := make([]byte, 64) r.FillBytes(sig[:32]) v.FillBytes(sig[32:]) return input + "." + b64.EncodeToString(sig), nil}
with this near the top of the file:
authserver/main.goGo
var b64 = base64.RawURLEncoding
The detail that catches everyone once
This variant is called base64url, and Go spells it RawURLEncoding. It is ordinary base64 with + and / swapped for - and _, and the = padding removed. Tokens travel in URLs and headers, where + and / already mean other things. Use standard base64 anywhere in this flow and nothing will verify, with an error message that tells you nothing useful.
An ECDSA signature is mathematically a pair of numbers, r and s. Different standards serialize that pair differently. JWS wants them as 64 raw bytes: 32 for each number, zero-padded on the left. FillBytes does exactly that. Remember this — Lesson 10 hits the same detail from the verifying side in Node.
Step 5: decide who may ask, and for what
authserver/main.goGo
// A user session, and the one service allowed to present it.//// Binding the session to a presenter is what stops any authenticated workload// from exchanging any session string it happens to know. mTLS proves WHO is// asking; this decides WHETHER they may ask for this.type session struct { user string presenter string // the actor permitted to exchange this session}var sessions = map[string]session{ "sess-go-8f3a": {user: "user-8f3a", presenter: "go-server"}, "sess-ts-2c19": {user: "user-2c19", presenter: "ts-server"},}// What each caller may ask for. An authorization server decides what it is// willing to grant; it does not sign whatever arrives.type grant struct { audiences map[string]bool scopes map[string]bool}var grants = map[string]grant{ "go-server": { audiences: map[string]bool{"https://ts-server.internal": true}, scopes: map[string]bool{"orders:read": true}, }, "ts-server": { audiences: map[string]bool{"https://go-server.internal": true}, scopes: map[string]bool{"orders:read": true}, },}
In a real system sessions and grants would be database tables. The shape is the point, and there are two shapes here rather than one:
sessions answers "which user does this opaque string mean, and who is allowed to present it";
grants answers "what may this caller ask for at all".
Keeping them apart matters. The first is about a user's session; the second is about a service's permissions. Merging them is how systems end up where any caller holding any session can mint anything.
What a session string is
The caller holds an opaque, presenter-bound session handle. It is still a security credential: the service stores it and sends it to the auth server as the RFC 8693 subject_token. What the service does not hold is the user's original browser or login credential, and it never forwards this handle to a resource server. Only the auth server can interpret the handle, and only its authorised presenter may exchange it.
Step 6: the exchange endpoint
authserver/main.goGo
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 } // *** THE MOST IMPORTANT LINE IN THIS LESSON *** // Verified by the TLS handshake. Nothing in the request body can change it. actor := r.TLS.PeerCertificates[0].Subject.CommonName 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 } // What is this caller allowed to ask for at all? 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 } // The session belongs to one presenter. Knowing the string is not enough. if sess.presenter != actor { fail(w, http.StatusBadRequest, "invalid_request", actor+" may not present this session") return } user := sess.user // Every requested scope must be one this caller may have. 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, // pins the token to ONE callee "sub": user, // who the call is FOR "act": map[string]string{"sub": actor}, // who is ACTING "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=%-10s sub=%-10s aud=%s scope=%s ttl=%s", actor, 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, })}
Look at the actor line one more time. It comes from the certificate, not from the request body. That is why a service cannot lie about who it is: it does not get to say.
Cache-Control: no-store and Pragma: no-cache are what RFC 6749 section 5.1 requires on any response carrying a token. They instruct well-behaved caches and proxies not to keep a copy. They are a direction, not an enforcement mechanism: a malicious or careless client can still write the token wherever it likes. What they buy you is that the ordinary infrastructure between you and the caller does not quietly retain credentials.
The shape of this endpoint — grant_type, subject_token, audience, scope — follows RFC 8693, the OAuth token exchange standard. You are not inventing a protocol; you are implementing a small, honest subset of a real one.
That includes the error responses. OAuth specifies a token-endpoint error body with a machine-readable error code, and section 5.2 of RFC 6749 makes 400 the default status for them. It is tempting to reach for 403 because the request was refused on authorization grounds, and it is wrong here — a client library written against the spec will not recognise it.
401 is the one exception, and it is narrower than it is usually described. The spec requires401 for invalid_client only when the client tried to authenticate through the HTTP Authorization header; otherwise it merely *permits* 401. Our clients authenticate with a certificate, not a header, so 401 here is a defensible choice rather than a rule.
The specific codes matter too. RFC 8693 section 2.2.2 is explicit that a subject_token which is invalid — or unacceptable under policy, which is what a presenter mismatch is — must come back as invalid_request. Reaching for invalid_grant is the intuitive move and the wrong one.
The fail helper keeps the error paths short:
authserver/main.goGo
// grantScope returns the requested scope only if EVERY scope in it is one the// caller may have. This is a subset check, not an intersection: asking for// something you may not have is an error rather than a silent downgrade, so a// caller that believes it holds orders:write finds out instead of quietly// receiving less than it asked for.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)}
Notice that the caller receives a short machine-readable code while the detailed reason goes only to the log. Error messages are a leak channel; say enough to be actionable, not enough to be a probing tool.
Step 7: publish the public key
authserver/main.goGo
// jwks publishes the public half, so verifiers never hold signing material and// therefore cannot forge a token even if fully compromised.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), } } // The current key first, then the previous one while it is still needed. // A verifier picks by kid, so publishing both is what lets tokens signed // before a rotation keep verifying after it. 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 below ignore this header. w.Header().Set("Cache-Control", "max-age=300") _ = json.NewEncoder(w).Encode(map[string]any{"keys": keys})}
JWKS stands for JSON Web Key Set: a standard URL that publishes the public keys used to sign tokens. Every real identity provider has one. You have now written one.
Notice it publishes a list, not a key. That plural is the whole rotation story.
What rotation actually requires
Say you replace the signing key at 12:00:00. Tokens minted at 11:59:55 are still valid until 12:00:55, and they carry the oldkid.
A verifier that sees an unknown kid refetches /jwks. If that document now contains only the new key, the refetch does not help — the old key is simply gone, and every outstanding token fails until it expires. Whether a given service breaks depends on whether it happened to cache the old key first, which is the worst kind of bug: intermittent, environment-dependent, and impossible to reproduce on demand.
The overlap rule
Publish the old key alongside the new one for longer than the maximum token lifetime. Sign with the new key immediately; retire the old one only after every token it signed has expired.
That is why signer carries a previous key and jwks publishes both when one exists. Three phases, in order:
Phase
Sign with
Publish
Before
Old
Old
Overlap, longer than one token lifetime
New
New and old
After
New
New
Two operational details belong with it. The first is in the handler above: Cache-Control: max-age=300 tells a compliant HTTP cache that it may reuse this JWKS response for five minutes. It does not expire the maps you write in Lesson 10; those teaching verifiers keep every public key they have seen until the process exits and ignore this header entirely.
For a production rotation, choose the HTTP cache lifetime as part of the rollout: publish the new key before signing with it, leave enough time for caches holding the old document to refresh, and keep the old key published until its last token has expired. The second detail is also not implemented here — make an unknown kid trigger at most one refetch per short interval, otherwise a caller sending random kid values turns your verifier into a traffic generator aimed at your auth server.
Where this demo falls short
previous is never populated here, because this course never rotates the signing key. The structure is in place so the mechanism is visible. A production auth server keeps signing keys in a durable store or a key management service, so that a restart does not lose them — which matters more than it sounds, and Lesson 13 shows exactly why.
x and y are the two coordinates of the public key's point on the curve. You do not need the mathematics. You need to know they are public and safe to serve to anyone.
Step 8: serve it
authserver/main.goGo
func tlsConfig() (*tls.Config, error) { cert, err := tls.LoadX509KeyPair("certs/auth.crt", "certs/auth.key") if err != nil { return nil, err } caPEM, err := os.ReadFile("certs/ca.crt") if err != nil { return nil, err } pool := x509.NewCertPool() if !pool.AppendCertsFromPEM(caPEM) { return nil, fmt.Errorf("no certificates found in certs/ca.crt") } return &tls.Config{ Certificates: []tls.Certificate{cert}, ClientCAs: pool, ClientAuth: tls.RequireAndVerifyClientCert, MinVersion: tls.VersionTLS13, }, nil}
This is Lesson 7's config with RootCAs left out, because the auth server never calls anybody. It only answers.
The auth server itself requires mutual TLS. That is how it knows the actor. Without it, the actor line in Step 6 would have nothing to read.
The complete auth server
Nothing below is new. It is Steps 3 to 8 in one file, in the order Go wants them, so that you can copy it in one go and compare against what you typed.
authserver/main.goGo
// Central token service.//// A service presents its own mTLS identity plus an opaque user session, and// gets back a short-lived JWT naming both -- the user in `sub`, the calling// service in `act`.//// The property that makes this worth having: the actor is read from the// caller's certificate, never from the request body.package mainimport ( "crypto/ecdsa" "crypto/rand" "crypto/sha256" "crypto/tls" "crypto/x509" "encoding/base64" "encoding/hex" "encoding/json" "encoding/pem" "errors" "fmt" "log" "math/big" "net/http" "os" "strings" "time")const ( port = "8445" issuer = "https://auth-server.internal" tokenTTL = 60 * 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.//// Binding the session to a presenter is what stops any authenticated workload// from exchanging any session string it happens to know. mTLS proves WHO is// asking; this decides WHETHER they may ask for this.type session struct { user string presenter string // the actor permitted to exchange this session}var sessions = map[string]session{ "sess-go-8f3a": {user: "user-8f3a", presenter: "go-server"}, "sess-ts-2c19": {user: "user-2c19", presenter: "ts-server"},}// What each caller may ask for. An authorization server decides what it is// willing to grant; it does not sign whatever arrives.type grant struct { audiences map[string]bool scopes map[string]bool}var grants = map[string]grant{ "go-server": { audiences: map[string]bool{"https://ts-server.internal": true}, scopes: map[string]bool{"orders:read": true}, }, "ts-server": { audiences: map[string]bool{"https://go-server.internal": 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 // The key we signed with before the most recent rotation, if any. We never // sign with it again, but we keep publishing it until every token it signed // has expired. See "What rotation actually requires" below. previous *ecdsa.PublicKey previousKid string}func loadSigner(path string) (*signer, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } block, _ := pem.Decode(data) if block == nil { return nil, fmt.Errorf("no PEM block in %s", path) } parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) if err != nil { return nil, err } key, ok := parsed.(*ecdsa.PrivateKey) if !ok { return nil, fmt.Errorf("%s is not an EC key", path) } // A stable key id, so verifiers can cache the public key across restarts. 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 scope only if EVERY scope in it is one the// caller may have. This is a subset check, not an intersection: asking for// something you may not have is an error rather than a silent downgrade, so a// caller that believes it holds orders:write finds out instead of quietly// receiving less than it asked for.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 } // Verified by the TLS handshake. Nothing in the request body can change it. actor := r.TLS.PeerCertificates[0].Subject.CommonName 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 } // What is this caller allowed to ask for at all? 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 } // The session belongs to one presenter. Knowing the string is not enough. if sess.presenter != actor { fail(w, http.StatusBadRequest, "invalid_request", actor+" may not present this session") return } user := sess.user // Every requested scope must be one this caller may have. 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, // pins the token to ONE callee "sub": user, // who the call is FOR "act": map[string]string{"sub": actor}, // who is ACTING "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=%-10s sub=%-10s aud=%s scope=%s ttl=%s", actor, 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 and// therefore cannot forge a token even if fully compromised.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), } } // The current key first, then the previous one while it is still needed. // A verifier picks by kid, so publishing both is what lets tokens signed // before a rotation keep verifying after it. 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 below ignore this header. w.Header().Set("Cache-Control", "max-age=300") _ = json.NewEncoder(w).Encode(map[string]any{"keys": keys})}func tlsConfig() (*tls.Config, error) { cert, err := tls.LoadX509KeyPair("certs/auth.crt", "certs/auth.key") if err != nil { return nil, err } caPEM, err := os.ReadFile("certs/ca.crt") if err != nil { return nil, err } pool := x509.NewCertPool() if !pool.AppendCertsFromPEM(caPEM) { return nil, fmt.Errorf("no certificates found in certs/ca.crt") } return &tls.Config{ Certificates: []tls.Certificate{cert}, ClientCAs: pool, ClientAuth: tls.RequireAndVerifyClientCert, MinVersion: tls.VersionTLS13, }, nil}func main() { log.SetFlags(log.Ltime) s, err := loadSigner("certs/token.key") if err != nil { log.Fatalf("signing key unavailable (run ./gen-certs.sh first): %v", err) } cfg, err := tlsConfig() if err != nil { log.Fatalf("tls setup failed (run ./gen-certs.sh first): %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, TLSConfig: cfg, } fmt.Printf("The Auth Server is running on port %s (https, mTLS required, kid=%s)\n", port, s.kid) log.Fatal(srv.ListenAndServeTLS("", ""))}
Mint your first token
Start it from the repository root, so the certs/ paths resolve:
zsh — 80×24
$go run ./authserver
$
zsh — 80×24
$The Auth Server is running on port 8445 (https, mTLS required, kid=078a5280945a539e)
$
Your kid will be different, because your key is different.
In another terminal, act as the Go service by handing curl that service's certificate:
Do this. Reading your own claims out of a token you minted makes everything in Lesson 10 concrete.
If base64 -d complains
Base64url drops the = padding, and some base64 implementations insist on it. Add up to two = characters to the end of the chunk until it decodes, or use base64 -di on macOS. This is the same padding quirk RawURLEncoding exists to handle.
Try to lie about who you are
The exchange endpoint never reads an actor from the body. Prove it: run the same request, but present the TypeScript service's certificate while asking for the Go service's session.
denied: invalid_request (ts-server may not present this session)
First, the actor came from the certificate. Nothing in the body could have changed it — the server knows it is talking to ts-server no matter what the request says.
Second, and this is the part that only works because of the first: knowing a session string is not enough to use it. Each session names the one service allowed to present it, so a workload that scraped sess-go-8f3a out of a log cannot exchange it.
That second check is easy to leave out, and leaving it out is a real hole. Without it, mTLS tells you who is asking and then you grant the request anyway — authentication doing the work of authorization, which it cannot do.
Try the other two refusals as well:
ask for a scope you were never granted
-d "scope=orders:write" -> {"error":"invalid_scope"}
denied: scope "orders:write" not permitted
ask for an audience you may not call
-d "audience=https://go-server.internal" -> {"error":"invalid_target"}
denied: go-server may not target …
What an authorization server is for
It decides what it is willing to grant. A server that signs whatever arrives is not an authorization server; it is a signing oracle with extra steps. RFC 8693 treats the requested scope as a request, and the issued scope as the server's answer to it.
That is the property the next lesson depends on.
Common errors
Error
Meaning
Fix
signing key unavailable
certs/token.key does not exist
Add the genpkey line to gen-certs.sh and rerun it
no PEM block in certs/token.key
The file is empty or truncated
Rerun ./gen-certs.sh without 2>/dev/null and read the real error
curl: (56) ... certificate required
You called /token without --cert and --key
The auth server requires mutual TLS
{"error":"invalid_target"}
The audience is not in the allow list
Use one of the two URLs in audiences
{"error":"invalid_request"}
Unknown subject_token
Use sess-go-8f3a or sess-ts-2c19
panic: runtime error: index out of range
ClientAuth is not RequireAndVerifyClientCert
Set it, so a certificate is guaranteed before the handler runs
Lesson 9 checkpoint
You are ready for Lesson 10 when you can explain all of these without rereading:
why the auth server holds a TLS key and a separate signing key;
what a kid is and how it makes key rotation possible without downtime;
why base64url, not standard base64, is used everywhere in a JWT;
why the actor comes from the certificate and not from the request body;
what JWKS publishes and why publishing it is safe;
why the error body is short while the log line is detailed.
Next you will teach both services to ask for these tokens, cache them, and — the part where the security actually lives — run six checks before believing one.