Serve both services over HTTPS with the certificates you just made, pin trust to your own certificate authority, and see exactly which of Lesson 4's five weaknesses this fixes.
Learning objectives
List the three guarantees TLS provides and the one it does not.
Walk through a TLS 1.3 handshake and say where possession of the private key is proven.
Serve HTTPS in Go and in Node using a local certificate and key.
Pin client trust to a private CA instead of the system trust store.
Demonstrate that one-way TLS still leaves the server unable to identify its caller.
What TLS does
TLS is the s in https. It does three things:
Encryption — nobody in the middle can read the traffic;
Integrity — nobody in the middle can change it without being detected;
Server authentication — the client can check it reached the right server.
Now notice what is missing from that list: the server learning who the client is.
Ordinary TLS is one-way. Your bank's website proves itself to your browser; your browser proves nothing back. That gap is what Lesson 7 closes. Today we do the other three.
Where the API key goes
It stays. TLS fixes eavesdropping, not identity, so something still has to answer "may this caller in?". The key survives one more lesson and then disappears completely.
What actually happens on the wire
Before you configure anything, watch one connection happen.
Conceptual, not a wire trace
The diagram below shows the messages that carry the ideas of this lesson. A real TLS 1.3 handshake also sends EncryptedExtensions and a Finished message from each side, and there is more inside each flight than one line can hold. If you want the actual bytes, RFC 8446 section 4 is the authority.
Flow· A TLS 1.3 handshake
CLIENT SERVER
1 "here are the versions and ciphers I
support, plus my half of a fresh
key exchange, plus a random number" ─────►
◄───── 2 "my half of the key exchange,
plus my own random number"
both sides now compute the SAME shared secret from the two halves.
everything from here on is encrypted.
◄───── 3 my certificate
◄───── 4 a signature over a hash of
everything we have both said
so far in this conversation
5 check the certificate:
signed by a CA I trust?
name matches who I dialled?
not expired?
then check the signature from
step 4 using the public key
inside that certificate ─────► 6 finished, start sending HTTP
Step 4 is the one that matters, and it has a name: CertificateVerify. It is where the server proves it actually owns the private key belonging to the certificate it just sent.
Think about why that step has to exist. A certificate is public — the server sends it to everyone who connects, so anyone can capture a copy. If sending a certificate were enough, an attacker could replay somebody else's and impersonate them. So the server must additionally prove it holds the matching private key, and it does that by signing.
Why the signature cannot be reused
What gets signed is a hash of the entire conversation so far, and that conversation includes a fresh random number from each side. A different connection produces different randoms, so it produces a different hash, so it needs a different signature. A captured signature is worthless anywhere except the one connection it was made for.
That is the concrete answer to the question Lesson 5 left open. The private key never travels. What travels is a signature that only works once, over data that only existed inside this one conversation.
Two smaller details worth noticing in that diagram:
The shared secret is computed, never sent. Each side sends half of a key exchange and both independently arrive at the same secret. An observer sees both halves and still cannot compute it. This is why recording the traffic and cracking it later does not work.
The certificate is sent encrypted. In TLS 1.3 everything after step 2 is already protected, so an eavesdropper cannot even see which certificate was presented. That is an improvement over TLS 1.2, where it was in the clear.
New ports
Encrypted traffic conventionally moves to a different port, so change both services:
Service
Was
Now
Go
8091
8443
TypeScript
8092
8444
The real HTTPS port is 443, but ports below 1024 need administrator rights on most systems. 8443 is the widely used stand-in that means "HTTPS, but not privileged". 8444 is simply the next one along.
Go: serve over HTTPS
New imports and constants
main.goGo
import ( "crypto/subtle" "crypto/tls" // NEW "crypto/x509" // NEW "encoding/json" "fmt" "io" "log" "net/http" "os" // NEW - to read the CA file "strings" "time")const ( port = "8443" // CHANGED peerURL = "https://127.0.0.1:8444/health" // CHANGED - https, new port caFile = "certs/ca.crt" certFile = "certs/go.crt" keyFile = "certs/go.key" apiKey = "super-secret-shared-key")
Load the CA we trust
main.goGo
// trustPool loads our CA into a certificate pool.func trustPool() (*x509.CertPool, error) { 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 pool, nil}
This is the most important function in the lesson, and the reason is not obvious.
By default a Go client trusts every public CA on the machine — roughly 150 organizations. So a certificate that anybody could buy for a few dollars would be accepted by your internal service. That is not a theoretical concern: it means the "is this the right server?" check is only as strong as the least careful CA on earth.
Building your own pool means trust exactly one issuer, ours.
Rule for service-to-service traffic
Pin to your own CA. The public trust store exists for the public internet, where you do not know your peers in advance. Inside your own systems, you do.
That reduces your trust surface from about 150 organizations to one. It also hands you the responsibilities that organization had: keeping the CA key safe, keeping the issuing service available, rotating the root without an outage, and recovering if you lose it. That is a good trade for internal traffic, and it is a trade rather than a free win.
Note the AppendCertsFromPEM check. It returns a boolean, not an error, and returns false when the file exists but contains no usable certificate. Ignoring it is how people end up with an empty trust pool that rejects everything for reasons the error message never explains.
Use it when calling out
poll now needs the pool, so it takes one as an argument. main builds the pool once at startup and passes it in.
main.goGo
func poll(pool *x509.CertPool) { client := &http.Client{ Timeout: 3 * time.Second, Transport: &http.Transport{ TLSClientConfig: &tls.Config{ RootCAs: pool, // verify the peer against OUR ca MinVersion: tls.VersionTLS13, // refuse older, weaker TLS }, }, } // ... the loop body is unchanged from Lesson 3}
MinVersion: tls.VersionTLS13 is this course's policy, and a good default for a system where you control both ends. Be aware it is a choice rather than a law: a correctly configured TLS 1.2 with modern cipher suites is still acceptable in many deployments, and Go's own default minimum is 1.2 precisely because libraries have to keep older clients working. Set the floor deliberately, and write down why.
Serve over TLS from Go
The change in main is small:
main.goGo
srv := &http.Server{ Addr: "127.0.0.1:" + port, Handler: mux, TLSConfig: &tls.Config{ MinVersion: tls.VersionTLS13, }, } fmt.Printf("The Go Server is running on port %s (https, api key required)\n", port) go poll(pool) // ListenAndServeTLS instead of ListenAndServe. We hand it our certificate. log.Fatal(srv.ListenAndServeTLS(certFile, keyFile))
That is the whole server-side change: ListenAndServe becomes ListenAndServeTLS(certFile, keyFile).
The complete Go file
main.goGo
package mainimport ( "crypto/subtle" "crypto/tls" "crypto/x509" "encoding/json" "fmt" "io" "log" "net/http" "os" "strings" "time")const ( port = "8443" // 8443 by convention means "HTTPS, not on the real 443" peerURL = "https://127.0.0.1:8444/health" caFile = "certs/ca.crt" certFile = "certs/go.crt" keyFile = "certs/go.key" apiKey = "super-secret-shared-key")func checkKey(given string) bool { return subtle.ConstantTimeCompare([]byte(given), []byte(apiKey)) == 1}// trustPool loads our CA into a certificate pool.//// Using this instead of the system default matters: by default a Go client// trusts every public CA on earth, so a certificate bought from any of them// would be accepted. We want exactly one issuer.func trustPool() (*x509.CertPool, error) { 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 pool, nil}func health(w http.ResponseWriter, r *http.Request) { if !checkKey(r.Header.Get("X-API-Key")) { log.Printf("<- (unknown) GET /health DENIED: bad or missing api key") w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) _ = json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) return } // Encrypted now -- but we still only know the caller had the key. log.Printf("<- (has key) GET /health") w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{ "service": "go", "status": "ok", })}func poll(pool *x509.CertPool) { client := &http.Client{ Timeout: 3 * time.Second, Transport: &http.Transport{ TLSClientConfig: &tls.Config{ RootCAs: pool, // verify the peer's certificate against our CA MinVersion: tls.VersionTLS13, }, }, } for { time.Sleep(3 * time.Second) req, err := http.NewRequest(http.MethodGet, peerURL, nil) if err != nil { log.Printf("--> ts %v", err) continue } req.Header.Set("X-API-Key", apiKey) resp, err := client.Do(req) 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) pool, err := trustPool() 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: &tls.Config{ MinVersion: tls.VersionTLS13, }, } fmt.Printf("The Go Server is running on port %s (https, api key required)\n", port) go poll(pool) // ListenAndServeTLS is the only difference from Lesson 3's ListenAndServe: // we hand it the certificate we present to callers. log.Fatal(srv.ListenAndServeTLS(certFile, keyFile))}
TypeScript: the same
Imports and files
server.tsTypeScript
import { createServer, request } from "node:https"; // node:https, not node:httpimport { readFileSync } from "node:fs";import { timingSafeEqual } from "node:crypto";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");const API_KEY = "super-secret-shared-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;
readFileSync at the top of the file is fine here: if a certificate is missing, the process should fail immediately and loudly rather than three seconds later inside a loop.
Say goodbye to fetch
fetch is pleasant to use, but it gives you no way to say "trust this CA" or, in Lesson 7, "present this client certificate". So from here on we use https.request, wrapped so it can be awaited:
server.tsTypeScript
function httpsGet( url: string, options: { ca: Buffer; headers?: Record<string, string> },): Promise<{ status: number; body: string }> { return new Promise((resolve, reject) => { const req = request( url, { ca: options.ca, headers: options.headers, minVersion: "TLSv1.3" }, (res) => { let body = ""; let received = 0; res.on("data", (chunk) => { // Count BYTES off the wire, before any decoding. `string.length` // counts UTF-16 code units, 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 }); }); }, ); // One overall deadline. Go's http.Client.Timeout covers the whole request; // Node has no equivalent by default, so we build it. const deadline = setTimeout( () => req.destroy(new Error("request deadline exceeded")), REQUEST_TIMEOUT_MS, ); req.on("error", (error) => { clearTimeout(deadline); reject(error); }); req.end(); });}
This is Node's older callback style wrapped in a Promise so you can await it. The pattern — collect data chunks, resolve on end — is worth learning, because you will see it everywhere in Node.
Serve over TLS from Node
server.tsTypeScript
const server = createServer({ cert, key, minVersion: "TLSv1.3" }, (req, res) => { // ... handler body unchanged from Lesson 3});
The only structural difference from Lesson 3 is the first argument: an options object carrying your certificate.
Use the CA when calling out
server.tsTypeScript
const res = await httpsGet(PEER_URL, { ca, headers: { "X-API-Key": API_KEY } });
Passing careplaces Node's default trust store, exactly like Go's RootCAs.
The complete TypeScript file
server.tsTypeScript
import { createServer, request } from "node:https";import { readFileSync } from "node:fs";import { timingSafeEqual } from "node:crypto";const PORT = 8444; // 8444 by convention means "HTTPS, not on the real 443"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");const API_KEY = "super-secret-shared-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 checkKey(given: string): boolean { const a = Buffer.from(given); const b = Buffer.from(API_KEY); return a.length === b.length && timingSafeEqual(a, b);}// A small promise wrapper around https.request.//// We cannot use fetch() from here on, because fetch gives us no way to say// "trust this CA" or (in Lesson 7) "present this client certificate".function httpsGet( url: string, options: { ca: Buffer; headers?: Record<string, string> },): Promise<{ status: number; body: string }> { return new Promise((resolve, reject) => { const req = request( url, { ca: options.ca, headers: options.headers, minVersion: "TLSv1.3" }, (res) => { let body = ""; let received = 0; res.on("data", (chunk) => { // Count BYTES off the wire, before any decoding. `string.length` // counts UTF-16 code units, 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 }); }); }, ); // One overall deadline. Go's http.Client.Timeout covers the whole request; // Node has no equivalent by default, so we build it. const deadline = setTimeout( () => req.destroy(new Error("request deadline exceeded")), REQUEST_TIMEOUT_MS, ); req.on("error", (error) => { clearTimeout(deadline); reject(error); }); req.end(); });}// createServer now comes from node:https and takes our certificate.const server = createServer({ cert, key, minVersion: "TLSv1.3" }, (req, res) => { if (req.url !== "/health") { res.writeHead(404); res.end(); return; } const given = req.headers["x-api-key"]; if (typeof given !== "string" || !checkKey(given)) { log("<- (unknown) GET /health DENIED: bad or missing api key"); res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "unauthorized" })); return; } // Encrypted now -- but we still only know the caller had the key. log("<- (has key) GET /health"); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ service: "typescript", status: "ok" }));});async function poll() { while (true) { await new Promise((r) => setTimeout(r, 3000)); try { // Passing `ca` replaces the system trust store. Without it, a certificate // from any public CA on earth would be accepted. const res = await httpsGet(PEER_URL, { ca, headers: { "X-API-Key": API_KEY } }); 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, api key required)`); poll();});
Run it and test
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, api key required)
17:22:14 <- (has key) GET /health
17:22:14 --> ts 200 OK {"service":"typescript","status":"ok"}
Test 1: plain HTTP is gone
zsh — 80×24
$curl http://127.0.0.1:8443/health
$
zsh — 80×24
$Client sent an HTTP request to an HTTPS server.
$
There is no unencrypted listener any more. The server refuses to speak plain HTTP, and the Go log records a TLS handshake error. The plaintext relay from Lesson 4 now has nothing readable to forward: the API key can no longer be lifted off the wire.
Test 2: curl does not trust our CA
zsh — 80×24
$curl https://127.0.0.1:8443/health
$
zsh — 80×24
$curl: (60) SSL certificate OpenSSL verify result: unable to get local issuer certificate (20)
$
The exact wording varies between curl versions; the number 60 and the phrase unable to get local issuer certificate are the parts to recognize.
This error is TLS working correctly. curl uses your system's trust store, which has never heard of secure-services-ca. Nothing is broken; you simply have not told curl whom to trust.
You are not the TypeScript server, and the Go server answered you.
The log still says (has key). TLS here is one-way: the client verified the server, never the reverse. The server has no idea who connected. It never even asked.
Of Lesson 4's five weaknesses, you fixed exactly one:
Weakness
Status after this lesson
Travels in plain text
Fixed
Proves possession, not identity
Unchanged
Both sides hold the same secret
Unchanged
Rotation needs a coordinated deploy
Unchanged
Leaks everywhere
Unchanged
But look at what is now sitting in your certs/ folder: a certificate and a private key for each service. Right now the client only uses ca.crt. In Lesson 7 the client presents its own certificate as well, and four rows of that table change at once.
Common errors
Error
Meaning
Fix
unable to get local issuer certificate
The client does not trust the CA
Pass --cacert certs/ca.crt, or set ca: / RootCAs: in code
certificate is valid for localhost, not X
The name inside does not match what you dialed
Add the name to subjectAltName in gen-certs.sh and rerun
no such file or directory: certs/ca.crt
Script not run, or wrong working directory
Run ./gen-certs.sh from inside secure-services
http: server gave HTTP response to HTTPS client
You used https:// against a plain-HTTP listener
Check the port and the scheme
certificate has expired
Exactly what it says
Rerun ./gen-certs.sh
Lesson 6 checkpoint
You are ready for Lesson 7 when you can explain all of these without rereading:
the three guarantees TLS gives and the one it does not;
why pinning to your own CA is stronger than using the system trust store;
why MinVersion belongs in every TLS config you write;
why fetch had to be replaced with https.request;
what unable to get local issuer certificate actually means;
why the API key is still doing all of the authentication work.
Next comes the most important lesson in the course. One setting on each side makes TLS mutual, the server learns the caller's real name, and you delete the password.