Require a certificate from the caller as well as the server, read a verified service name off the connection, and delete the shared password from your codebase entirely.
Learning objectives
Explain why proving possession of a private key beats transmitting a secret.
Name the three handshake messages mutual TLS adds and what each one proves.
Configure mutual TLS in Go and in Node, and name the setting that makes it mutual.
Read a caller's verified identity from the TLS layer instead of a header.
Distinguish a claim the caller asserts from a fact the handshake proved.
The one-word change
In Lesson 6 the client checked the server. Now both check each other.
That is all mutual TLS is. In code it is one setting on each side:
Plus one word in the certificate script. The rest of this lesson is about understanding why that is so much better than what you had.
Why a certificate beats a password
Read this section twice. It is the key idea of the whole course.
With a password, the caller sends the secret. Whoever hears it can repeat it. The secret is the credential, so the credential can be copied. You watched exactly that happen in Lesson 4.
With a certificate, the caller never sends its private key. During the handshake it signs a value derived from this specific conversation and sends the signature.
Anyone watching sees a signature that is useless anywhere else. Replay it on a different connection and it fails, because a different connection produces a different value to sign.
The sentence to remember
One change — proving possession instead of transmitting the secret — removes eavesdropping, replay, and the both-sides-know-it problem at the same time.
What changes in the handshake
Lesson 6 walked through a handshake where only the server proved itself. Mutual TLS adds three messages: the server asks, and the client answers with the same two the server already sent.
The diagram is conceptual in the same way Lesson 6's was — the Finished messages and EncryptedExtensions are still there, just not drawn.
Flow· The mutual handshake, changes marked NEW
CLIENT SERVER
1 hello + key exchange half + random ─────►
◄───── 2 hello + key exchange half + random
◄───── 3 NEW: "and send me your certificate"
◄───── 4 my certificate
◄───── 5 my signature over the conversation
6 check the server's certificate
and signature (as in Lesson 6)
7 NEW: my certificate ─────►
8 NEW: my signature over the
conversation ─────►
9 check the client's certificate
and signature the same way
10 both proven, start HTTP
That is the entire protocol change. Step 3 is the server asking, and steps 7 and 8 are the client answering with exactly the same two messages the server sent at 4 and 5.
Everything you learned in Lesson 6 applies unchanged in the new direction:
Question
Answer
Does the client send its private key?
No. It signs the conversation, same as the server did
Can the server replay that signature elsewhere?
No. It only covers this one conversation
What does the server check?
Chain signed by a trusted CA, then the signature
Where does this happen?
During the handshake, before any HTTP exists
Why the client is not name-checked
The server verifies the client's certificate chain, but it does not check a hostname, because a client does not have one — nobody dialled it. That is why the client's identity comes from the name inside the certificate rather than from an address. Lesson 11 takes this idea much further.
Regenerate the certificates for both roles
Open gen-certs.sh and change one line inside the extension file:
gen-certs.sh — zsh
$extendedKeyUsage=serverAuth,clientAuth
$
It previously said serverAuth only.
A certificate declares what it may be used for. Until now yours said "I may prove a server". Each service is now both: it serves /health and it calls the other one's /health. So it needs both purposes.
TLS Web Server Authentication, TLS Web Client Authentication
$
If you skip this
The handshake fails later with x509: certificate specifies an incompatible key usage, which is a confusing way of saying "this certificate is not allowed to act as a client". Now you know what it means.
Go: one config for both directions
Build the TLS config
Replace Lesson 6's trustPool with this:
main.goGo
// tlsConfig builds ONE config used in both directions://// Certificates -> the identity we present, whichever role we are in// RootCAs -> whose certificates we accept when WE are the client// ClientCAs -> whose certificates we accept when WE are the server// ClientAuth -> the switch that makes this MUTUAL instead of ordinary TLSfunc 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, // no cert, no request MinVersion: tls.VersionTLS13, }, nil}
Read the four fields again against the comment. RootCAs is consulted when we call out. ClientCAs is consulted when someone calls us. Same pool, two roles, because our service is on both ends of different connections.
tls.RequireAndVerifyClientCert is the whole lesson: demand a certificate, and check it.
Read the caller's name in Go
Delete checkKey and the apiKey constant. All of it. Then rewrite the handler:
main.goGo
func health(w http.ResponseWriter, r *http.Request) { // This handler only ever runs for a caller whose certificate already passed // the handshake. So the CN below is VERIFIED identity -- not a header the // caller typed in and not a shared secret anyone could copy. caller := r.TLS.PeerCertificates[0].Subject.CommonName log.Printf("<- %-13s GET /health", caller) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{ "service": "go", "status": "ok", "caller": caller, })}
r.TLS.PeerCertificates[0] is the certificate the caller proved they own. Indexing [0] directly is safe only becauseRequireAndVerifyClientCert guarantees there is one: without a valid certificate the request never reaches this function. Change that setting and this line becomes a crash waiting to happen.
What this does and does not establish
Four separate things are happening, and it is worth keeping them apart. The handshake proved the caller holds a private key. The chain check proved a CA you trust issued the matching certificate. Reading the common name extracted an identity. None of that is authorization. This server currently accepts any certificate your CA issued — so the name tells you who called, and nothing yet decides whether they may.
That last gap is deliberate at this point in the course, and it is the reason a name is worth having at all: you cannot write "only the checkout service may issue refunds" until there is a name to write it about. Lesson 12 adds the allow list that turns this identity into a decision.
%-13s pads the name to thirteen characters so the log columns line up.
Present our certificate from Go
main.goGo
func poll(cfg *tls.Config) { client := &http.Client{ Timeout: 3 * time.Second, Transport: &http.Transport{TLSClientConfig: cfg}, } for { time.Sleep(3 * time.Second) resp, err := client.Get(peerURL) // back to plain Get -- no headers needed if err != nil { log.Printf("--> ts unreachable: %v", err) continue } body, err := io.ReadAll(resp.Body) 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))) }}
Because the config carries Certificates, this client automatically presents our certificate whenever a server asks for one. You write no extra code for it.
Notice we are back to client.Get(url). No more building a request object to attach a header. The identity now lives in the connection, not in the message.
Wire it up
main.goGo
func main() { log.SetFlags(log.Ltime) cfg, err := tlsConfig() if err != nil { log.Fatalf("tls setup failed (run ./gen-certs.sh first): %v", err) } mux := http.NewServeMux() mux.HandleFunc("/health", health) 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 required)\n", port) go poll(cfg.Clone()) // The certificate comes from TLSConfig now, so both arguments stay empty. log.Fatal(srv.ListenAndServeTLS("", ""))}
Two details:
ListenAndServeTLS("", "") takes empty strings, because the certificate now comes from TLSConfig instead of from filenames.
cfg.Clone() gives the HTTP client its own copy rather than sharing one object between the server and the transport. It costs nothing and avoids a whole class of subtle bug where one side mutates state the other depends on.
The complete Go file
main.goGo
package mainimport ( "crypto/tls" "crypto/x509" "encoding/json" "fmt" "io" "log" "net/http" "os" "strings" "time")const ( port = "8443" peerURL = "https://127.0.0.1:8444/health" caFile = "certs/ca.crt" certFile = "certs/go.crt" keyFile = "certs/go.key")// tlsConfig builds ONE config used in both directions://// Certificates -> the identity we present, whichever role we are in// RootCAs -> whose certificates we accept when WE are the client// ClientCAs -> whose certificates we accept when WE are the server// ClientAuth -> the switch that makes this MUTUAL instead of ordinary TLSfunc 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, // no cert, no request MinVersion: tls.VersionTLS13, }, nil}func health(w http.ResponseWriter, r *http.Request) { // This handler only ever runs for a caller whose certificate already passed // the handshake. So the CN below is VERIFIED identity -- not a header the // caller typed in and not a shared secret anyone could copy. caller := r.TLS.PeerCertificates[0].Subject.CommonName log.Printf("<- %-13s GET /health", caller) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{ "service": "go", "status": "ok", "caller": caller, })}func poll(cfg *tls.Config) { // Same config as the server side. Because it carries Certificates, this // client automatically presents our cert when the peer asks for one. client := &http.Client{ Timeout: 3 * time.Second, Transport: &http.Transport{TLSClientConfig: cfg}, } for { time.Sleep(3 * time.Second) resp, err := client.Get(peerURL) if err != nil { log.Printf("--> ts unreachable: %v", err) continue } body, err := io.ReadAll(resp.Body) 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) } mux := http.NewServeMux() mux.HandleFunc("/health", health) 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 required)\n", port) go poll(cfg.Clone()) // The certificate comes from TLSConfig now, so both arguments stay empty. log.Fatal(srv.ListenAndServeTLS("", ""))}
TypeScript: the same
Require a certificate back
server.tsTypeScript
import { createServer, request } from "node:https";import { readFileSync } from "node:fs";import type { TLSSocket } from "node:tls"; // NEWconst ca = readFileSync("certs/ca.crt");const cert = readFileSync("certs/ts.crt");const key = readFileSync("certs/ts.key");// 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;
server.tsTypeScript
const server = createServer( { cert, key, ca, // whose client certificates we accept requestCert: true, // ask the caller for one rejectUnauthorized: true, // <- the switch that means "mutual" minVersion: "TLSv1.3", }, (req, res) => { /* handler below */ },);
Never write one without the other
requestCert: true on its own asks the caller for a certificate and then accepts them anyway if they have none. That is a genuinely dangerous half-configuration, because the code looks like it is checking something. rejectUnauthorized: true is what makes the check binding.
req.socket as TLSSocket is a TypeScript cast: "I know this is a TLS socket, so let me call TLS methods on it." It is safe here because this server only ever accepts TLS connections.
Present our certificate from Node
server.tsTypeScript
function httpsGet(url: string): Promise<{ status: number; body: string }> { return new Promise((resolve, reject) => { // cert + key are new here: we now present OUR identity when we call out. const req = request(url, { ca, cert, key, minVersion: "TLSv1.3" }, (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(); });}
Compared with Lesson 6, we added cert and key and dropped the headers argument. That is all.
Log rejected handshakes
server.tsTypeScript
// A rejected handshake is normal output here, not a crash.server.on("tlsClientError", (error) => { log(`!! handshake rejected: ${error.message}`);});
Without this, refused connections are completely silent and you will think nothing happened at all. Add it now, because you are about to want it.
The complete TypeScript file
server.tsTypeScript
import { createServer, request } from "node:https";import { readFileSync } from "node:fs";import type { TLSSocket } from "node:tls";const PORT = 8444;const PEER_URL = "https://127.0.0.1:8443/health";const ca = readFileSync("certs/ca.crt");const cert = readFileSync("certs/ts.crt");const key = readFileSync("certs/ts.key");// 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;function log(message: string) { console.log(new Date().toTimeString().slice(0, 8), message);}function httpsGet(url: string): Promise<{ status: number; body: string }> { return new Promise((resolve, reject) => { // cert + key are new here: we now present OUR identity when we call out. const req = request(url, { ca, cert, key, minVersion: "TLSv1.3" }, (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, // whose client certificates we accept requestCert: true, // ask the caller for one rejectUnauthorized: true, // <- the switch that means "mutual" minVersion: "TLSv1.3", }, (req, res) => { if (req.url !== "/health") { res.writeHead(404); res.end(); return; } // This handler only ever runs for a caller whose certificate already passed // the handshake. So this CN is VERIFIED identity -- not a header the caller // typed in, and not a shared secret anyone could copy. const socket = req.socket as TLSSocket; const caller = socket.getPeerCertificate().subject.CN; log(`<- ${caller.padEnd(13)} GET /health`); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ service: "typescript", status: "ok", caller })); },);// A rejected handshake is normal output here, not a crash.server.on("tlsClientError", (error) => { log(`!! handshake rejected: ${error.message}`);});async function poll() { while (true) { await new Promise((r) => setTimeout(r, 3000)); try { const res = await httpsGet(PEER_URL); 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 required)`); poll();});
Run it and see the difference
zsh — 80×24
$./gen-certs.sh
$go run .
$
zsh — 80×24
$node server.ts
$
go output
The Go Server is running on port 8443 (https, mTLS required)
17:23:40 <- ts-server GET /health
17:23:40 --> ts 200 OK {"service":"typescript","status":"ok","caller":"go-server"}
Look at how the incoming log line has changed across the course:
Lesson
The receiving service logs
Because
2
(unknown)
Nothing identified the caller
3 to 6
(has key)
The caller held a copyable string
7
ts-server
A name read off a verified certificate
Nobody can type ts-server into a header. Nobody can copy it off the wire.
curl is now genuinely acting as ts-server, because you handed it that service's private key. Which is a useful reminder of why .key files matter and why chmod 600 was not decoration.
Two rules to take to work
Rule 1: headers are claims, TLS is proof
The distinction
Anything from req.headers is a claim the caller made. Anything from the TLS layer is a fact the handshake proved.
A header is text the caller typed. It can say anything: X-User-Id: 1, X-Admin: true, X-Internal: yes. All trivially forged by whoever can reach your port.
Two consequences follow:
Never authenticate on a header your gateway does not strip. If your application trusts X-User-Id, then anyone who can reach the application port directly is authenticated as anyone.
The exception proves the rule. A header can be trustworthy — but only inside a boundary you enforce, where a proxy you control strips the header on the way in, sets it itself, and nothing can reach your port except through that proxy. That is a real pattern. It is also three conditions that must all hold, any one of which can be quietly broken by a networking change, which is why identity carried by the connection is the sturdier default.
Log the authenticated identity on every request. "Which service did this?" is the first question asked when something goes wrong, and it is unanswerable if you never recorded it.
Rule 2: failing early is a feature
An unauthorized caller is dropped during the handshake, before a single byte of HTTP is parsed.
Your router never sees it. Your JSON parser never sees it. Your handler never runs. That is far less code exposed to a stranger than any header check, which by definition runs after your framework has already processed the request.
Score card
Every weakness from Lesson 4, revisited:
Weakness in Lesson 4
Status now
Key travels in plain text
The private key never leaves the machine
Proves possession, not identity
The CN is a real name you can write rules about
Both sides share one secret
Each side has its own private key
Rotation needs a coordinated deploy
Certificates are reissued per service, independently
Leaks into logs and config
There is no secret string to paste anywhere
All five, addressed. Your codebase no longer contains a password.
The gap certificates cannot close
Now the hard question.
ts-server calls GET /health. Fine. But imagine it calls GET /orders/12345.
Mutual TLS tells you which service is asking. It tells you nothing about which user the request is for.
So if the TypeScript service has a bug in one route — it forgot to check that order 12345 belongs to this logged-in user — the Go server hands the data over. Happily. Because the service identity is perfectly, genuinely valid.
This is called the confused deputy problem: a trusted service doing untrusted work on someone else's behalf. The deputy is not malicious. It is confused.
You cannot fix it with certificates, because certificates identify services, not people. You need a second credential that carries the user, and that is where Chapter 4 begins.
Common errors
Error
Meaning
Fix
x509: certificate specifies an incompatible key usage
The certificate lacks clientAuth
Add it to gen-certs.sh and rerun the script
tls: client didn't provide a certificate
Working as intended: the caller sent none
Pass --cert and --key to curl
bad certificate (alert 42)
The peer's certificate was rejected
Both sides must trust the same ca.crt
Go panics on PeerCertificates[0]
ClientAuth is not RequireAndVerifyClientCert
Set it, so a certificate is guaranteed
Node accepts callers with no certificate
You set requestCert but not rejectUnauthorized
Always set both
Lesson 7 checkpoint
You are ready for Lesson 8 when you can explain all of these without rereading:
in one sentence, why an eavesdropper cannot reuse what they see during an mTLS handshake;
what RootCAs, ClientCAs, and ClientAuth each control;
why X-User-Id: 42 is not authentication;
why rejecting during the handshake exposes less code than rejecting in a handler;
why indexing PeerCertificates[0] is safe here and would not be otherwise;
why mTLS is still not enough to safely return order 12345.
Next you will meet the confused deputy properly, and design the second credential that closes the gap.