and the Go service answered you as if you were the TypeScript service.
You authenticated as a service, from your own terminal, by copying a string out of a source file. Nothing about you was checked.
That is the whole problem, in one line you typed yourself. The rest of this lesson makes it precise.
Experiment: read the key off the wire
The claim "it travels in plain text" is easy to say and easy to disbelieve. Let us prove it.
You are going to put a small relay between the two services. It forwards every byte and prints what passes through. This is exactly what a compromised proxy, a debugging sidecar, or anything else on the network path can do.
This is a temporary experiment
The file below and the config change after it both get undone at the end of this section. Do not leave either in place. The steps to restore are listed, and you should follow them before Lesson 5.
Step 1 — create the relay
Create a file called eavesdrop.ts:
eavesdrop.tsTypeScript
// TEMPORARY -- delete this file at the end of Lesson 4.//// A relay that sits between the two services and prints everything the caller// sends. Anything on the network path can do this.import { connect, createServer } from "node:net";const LISTEN_PORT = 8093;const TARGET_PORT = 8092;createServer((caller) => { const upstream = connect(TARGET_PORT, "127.0.0.1"); caller.on("data", (chunk) => { console.log("--- caller sent ---"); console.log(chunk.toString()); upstream.write(chunk); }); upstream.on("data", (chunk) => caller.write(chunk)); // If either side goes away, drop the other one too. caller.on("error", () => upstream.destroy()); upstream.on("error", () => caller.destroy()); caller.on("close", () => upstream.destroy()); upstream.on("close", () => caller.destroy());}).listen(LISTEN_PORT, "127.0.0.1", () => { console.log(`Eavesdropper on ${LISTEN_PORT}, forwarding to ${TARGET_PORT}`);});
Step 2 — point the Go service through it
In main.go, change one line:
main.goGo
peerURL = "http://127.0.0.1:8093/health" // TEMPORARY: was 8092
Step 3 — run all three
zsh — 80×24
$node eavesdrop.ts
$
zsh — 80×24
$node server.ts
$
zsh — 80×24
$go run .
$
Within three seconds the eavesdropper prints something very close to this:
eavesdropper outputHTTP
--- caller sent ---GET /health HTTP/1.1Host: 127.0.0.1:8093User-Agent: Go-http-client/1.1X-Api-Key: super-secret-shared-keyAccept-Encoding: gzip
There it is. Not encoded, not obscured, not hashed. The credential, in full, readable by anything on the path.
And the relay did not need to break anything to get it. It only needed to be in the middle.
What this proves
Whoever reads that line is your service from now on. They do not need to break in again, and nothing about the next request will look different to the receiver. There is no expiry and no way for you to notice.
Step 4 — restore
Undo both changes now:
zsh — 80×24
$rm eavesdrop.ts
$
In main.go, put the port back:
main.goGo
peerURL = "http://127.0.0.1:8092/health"
Restart both services and confirm the normal (has key) lines return before you continue.
Possession is not identity
Look at the log line the receiving service writes:
go output
<- (has key) GET /health
That is the most honest thing it can print. The server knows the caller had the key. It does not know who the caller is.
Those are different facts, and confusing them is the mistake this course exists to fix.
Question
A shared key answers
What you actually needed
Does the caller hold a secret we recognize?
Yes
Not enough on its own
Which service is the caller?
Cannot say
The name you write rules about
Which user is the call for?
Cannot say
Lessons 8 to 10
Ten services sharing one key are completely indistinguishable to the receiver. That means you can never write a rule like "only the checkout service may issue refunds", because there is no name to write the rule about.
The five weaknesses
1. It travels in plain text
You just watched this happen. On http://, anyone on the network path reads the key off the wire and becomes your service permanently.
2. It proves possession, not identity
The receiver learns that the caller has a key, not which caller. No authorization rule can be written about a caller with no name.
3. Both sides hold the same secret
This one surprises people. If your payments service holds the same key that orders uses to call it, then anyone who breaks into payments can now pretend to be orders everywhere else in your system.
A credential that both parties know is a credential either party can use.
4. Rotating it means downtime
To change the key, every service that holds it must switch at the same instant. That is a coordinated deploy, usually across teams who own different release schedules. In practice people postpone it, and the same key sits there for five years.
5. It leaks everywhere
Config files. Environment dumps. CI logs. Error reports. A screenshot pasted into a chat channel. Every one of those is a normal, boring, extremely common way that keys escape — and none of them looks like an attack while it is happening.
Three properties, not one root cause
It is tempting to blame every row on one dramatic sentence. Do not. Three independent design choices are interacting:
Property of the Lesson 3 design
Weaknesses it creates
The key is a bearer secret sent on every request
On plain HTTP it is readable on the path. Any component that obtains the value can replay it until it expires or is revoked
The same value is shared by every caller and the receiver
Callers are indistinguishable, one leak compromises the fleet, and the receiver can impersonate a caller
There is no expiry or overlap mechanism
Rotation requires every holder to change together
The portable string also creates many ordinary leak paths: source, configuration, logs, dumps, screenshots, and request tracing. Sending it adds more opportunities, but transmission is not what makes ten callers share one identity. Sharing one value does that.
The sentence to remember
A copied bearer credential works like the original. A shared credential also makes all of its holders look alike. Those are different failures.
Now consider the change everybody suggests first:
"Move the key out of source code and into an environment variable."
Work through the list honestly:
Weakness
Fixed by an environment variable?
Travels in plain text
No
Proves possession, not identity
No
Both sides hold the same secret
No
Rotation needs a coordinated deploy
No
Leaks everywhere
Partly — it removes one leak path and adds others
One partial improvement out of five. The change is still worth making, but calling it a fix would be a mistake, and recognizing that difference is most of what separates security theater from security work.
The strongest version of the API key, for fairness
The design in Lesson 3 is the weakest one: a single string shared by everybody. It is also the one most systems actually have, which is why it is worth attacking. But an API key scheme can be built much better than that, and you should know what the good version looks like so you are criticizing the right thing.
Weakness
Does a well-built API key scheme fix it?
Proves possession, not identity
Yes. One key per caller, with a key ID, maps to a named service you can authorize
Rotation needs a coordinated deploy
Yes. Accept old and new during an overlap, exactly like Lesson 9's signing keys
Both sides hold the same secret
Only at rest. Storing a hash protects the key database if it is stolen. It does nothing about a live receiver, which sees the plaintext key on every request and can replay it
Travels in plain text
No. Still sent on every request
Leaks everywhere
No. Still a string that can be copied out of a log
So three of the five are solvable or substantially reduced with care. TLS can also stop a passive network observer reading the header. What survives is the defining bearer property: the receiver still sees the reusable value on every request, and a copy obtained from the caller, receiver, proxy, trace, or log works like the original until it expires, is rotated, or is revoked.
That is the honest comparison to make in a design review. Not "API keys are bad" — which invites a fair reply that yours are per-service, carried inside TLS, and hashed at rest — but "a copied bearer key can be replayed without proving possession of anything else."
What actually fixes it
TLS can use a credential whose private key never leaves the machine that owns it. The certificate itself is public: it binds a name to the corresponding public key. During the handshake, the owner uses the private key to sign this connection's fresh transcript and sends only the signature.
An eavesdropper who copies that signature cannot reuse it in the next handshake, because the next connection has a different transcript. The replay resistance comes from that fresh proof-of-possession protocol, not from a certificate file by itself.
TLS encryption addresses the first weakness. Adding a distinct client certificate per service addresses the next two: the certificate supplies a service name, and the receiver never learns the caller's private key. Per-service leaf certificates also make leaf rotation independent instead of fleet-wide. Private keys can still leak from disk or process memory, so Lesson 11 later replaces long-lived files with short-lived, in-memory SVIDs. You build the first certificate in Lesson 5.
The road from here
Lesson
What it adds
What is still wrong afterward
5
Your own certificate authority
Nothing uses the certificates yet
6
TLS, so traffic is encrypted
The server still has no idea who is calling
7
Mutual TLS, so both sides prove identity
Proves which service, not which user
8 to 10
A token that carries the user, and authorization on top of it
You are ready for Lesson 5 when you can explain all of these without rereading:
what you observed in the eavesdropper output and why it needed no exploit;
the difference between proving possession and proving identity;
why a shared secret makes "only service A may call this" unwritable;
why breaking into the receiving service also compromises the calling service;
why moving a secret to an environment variable fixes almost nothing;
what property a credential needs so that copying the traffic is useless.
Next you will build the thing that has that property: a small certificate authority of your own, and one certificate per service. No application code changes in that lesson — it is about understanding the files before you depend on them.