Lesson 3 of 15 · 30 min
Guard the API with a Shared Key
Add the credential everybody reaches for first — one shared string in a header — and learn why comparing a secret with == can leak it one character at a time.
Learning objectives
- Explain what an HTTP header is and why a shared secret is called shared.
- Describe a timing attack and why constant-time comparison defeats it.
- Reject unauthenticated callers with 401 in Go and in Node.
- Attach a credential to an outgoing request in both languages.
The idea
Two services need to trust each other, so: give them both the same password.
The caller puts it in a header. The receiver compares it to its own copy. If they match, the caller is let in.
GET /health
X-API-Key: super-secret-shared-keyA header is extra information attached to a request, separate from the body. The X- at the front is an old convention meaning "this is a custom header, not one from the HTTP standard".
The string both sides hold is called a shared secret, because both of them know it. Hold on to that word. Lesson 4 spends its whole length on the consequences.
What you are building
By the end of this lesson, a stranger who runs curl against your service gets 401 Unauthorized, and the two services still talk to each other normally. That is real progress, and it is also a trap. Lesson 4 explains which parts were real.
Go: check the key
One new import
import (
"crypto/subtle" // NEW - for safe secret comparison
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
)Add the key
const (
port = "8091"
peerURL = "http://127.0.0.1:8092/health"
// Both services hold the SAME string. That is what "shared secret" means,
// and it is the root of every problem with this approach.
apiKey = "super-secret-shared-key"
)Write the check
This is the important part of the lesson, and it is three lines long:
// checkKey compares in constant time.
func checkKey(given string) bool {
return subtle.ConstantTimeCompare([]byte(given), []byte(apiKey)) == 1
}You are probably wondering why it is not simply this:
return given == apiKey // DO NOT DO THISBecause == gives you no promise of constant-time behavior. Here is the failure mode you are avoiding.
Why == is dangerous: the timing attack
A deliberately naive string comparison might look like this:
for each position i:
if given[i] differs from secret[i]:
return false
return trueThat comparator stops at the first differing byte. A guess with a longer correct prefix therefore takes slightly longer—perhaps nanoseconds longer.
An attacker cannot see nanoseconds in one request. But they can send a million requests and take the average, and then:
- try
aXXXXX,bXXXXX,cXXXXX, and so on — one of them is measurably slower; - that slower one has the correct first character, so lock it in;
- repeat for character two.
Against that naive comparator, the work goes from "guess the whole key" to "guess one character at a time". For a 32-character key drawn from 62 possible characters, that is at most 62 x 32 = 1,984 candidates to try, instead of the 62^32 combinations a blind search would need.
Be careful with that number, because it is candidates and not requests. Each candidate needs many repeated measurements before the difference rises above network jitter, so the real cost is candidates multiplied by samples — thousands of requests per position, not per key. Remote timing attacks over a network are difficult and depend on conditions you do not control. They are also not hypothetical: this class of attack has broken real production systems, and defending against it costs one function call.
Go and JavaScript do not specify that == uses the byte-by-byte loop above. A runtime may compare words or vectors, intern strings, or optimize a fixed-length comparison in some other way. That means the precise leakage of == is implementation-dependent—not that it is safe. The language gives you no constant-time guarantee, so security code should use an API that does.
subtle.ConstantTimeCompare compares every byte of two equal-length inputs, so the time it takes does not depend on where they differ. That is what closes the attack above.
One caveat the documentation states plainly and people miss: if the two inputs have different lengths it returns immediately. Constant-time helpers hide content-dependent timing, not length-dependent timing. Leaking the length of a secret is usually acceptable; leaking its contents is not.
Rule for life
Do not use == for raw API keys, session tokens, or MAC bytes; use the constant-time comparison your language provides. For passwords, use the password-hashing library's verify function. For signatures, use the signature algorithm's verification API. Protocol-specific verifiers do more than compare two byte strings.
Guard the handler
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) // this is 401
_ = json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
return
}
// The key was right -- but a key proves only that the caller HAS the key,
// not that the caller is who we think. Anyone holding it looks identical.
log.Printf("<- (has key) GET /health")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{
"service": "go",
"status": "ok",
})
}Two things to notice.
The log message changed. It went from (unknown) to (has key). That is an upgrade, but read it carefully. It says the caller has the key. It does not say who the caller is. That gap is the entire subject of Lesson 4.
The 401 branch ends with a return. Without it, execution carries on and sends a success reply as well. Header and status ordering matters here too: Header().Set must come before WriteHeader, because once the status line goes out the headers are already on the wire.
Send the key when we call out
client.Get(url) gives you nowhere to attach headers, so we build the request object first:
func poll() {
client := &http.Client{Timeout: 3 * time.Second}
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) // sent in the clear, every time
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)))
}
}Two changes from Lesson 2: http.NewRequest plus req.Header.Set, then client.Do(req) instead of client.Get(url).
That comment on the header line — sent in the clear, every time — is not decoration. Keep it in your file. Lesson 4 comes back to it.
The complete Go file
package main
import (
"crypto/subtle"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
)
const (
port = "8091"
peerURL = "http://127.0.0.1:8092/health"
// Both services hold the SAME string. That is what "shared secret" means,
// and it is the root of every problem with this approach.
apiKey = "super-secret-shared-key"
)
// checkKey compares in constant time.
//
// Using `==` would return as soon as two bytes differ, so an attacker could
// measure response times to guess the key one character at a time.
func checkKey(given string) bool {
return subtle.ConstantTimeCompare([]byte(given), []byte(apiKey)) == 1
}
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
}
// The key was right -- but a key proves only that the caller HAS the key,
// not that the caller is who we think. Anyone holding it looks identical.
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() {
client := &http.Client{Timeout: 3 * time.Second}
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) // sent in the clear, every time
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)
mux := http.NewServeMux()
mux.HandleFunc("/health", health)
fmt.Printf("The Go Server is running on port %s (api key required)\n", port)
go poll()
log.Fatal(http.ListenAndServe("127.0.0.1:"+port, mux))
}TypeScript: the same
Import and constant
import { createServer } from "node:http";
import { timingSafeEqual } from "node:crypto"; // NEW
const PORT = 8092;
const PEER_URL = "http://127.0.0.1:8091/health";
// Both services hold the SAME string.
const API_KEY = "super-secret-shared-key";The safe comparison
function checkKey(given: string): boolean {
const a = Buffer.from(given);
const b = Buffer.from(API_KEY);
// timingSafeEqual throws if the lengths differ, so check that first.
return a.length === b.length && timingSafeEqual(a, b);
}Buffer.from() turns text into raw bytes, which is what timingSafeEqual needs.
Does the length check leak the length?
Yes, slightly. That is considered an acceptable trade, because knowing a key is 23 characters long helps an attacker almost not at all compared with knowing its contents. Skipping the check is not an option: timingSafeEqual throws on mismatched lengths, and a thrown exception is its own timing signal.
Guard the Node handler
const server = createServer((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;
}
// The key was right -- but a key proves only that the caller HAS the key,
// not that the caller is who we think. Anyone holding it looks identical.
log("<- (has key) GET /health");
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ service: "typescript", status: "ok" }));
});Two Node details catch people out here:
- Header names arrive lowercase. You must write
req.headers["x-api-key"]even though the sender wroteX-API-Key. - A header can be an array if the caller sent it twice, which is why we check
typeof given !== "string"before comparing.
Send the key
const response = await fetch(PEER_URL, {
signal: AbortSignal.timeout(3000),
headers: { "X-API-Key": API_KEY }, // sent in the clear, every time
});The complete TypeScript file
import { createServer } from "node:http";
import { timingSafeEqual } from "node:crypto";
const PORT = 8092;
const PEER_URL = "http://127.0.0.1:8091/health";
// Both services hold the SAME string. That is what "shared secret" means, and
// it is the root of every problem with this approach.
const API_KEY = "super-secret-shared-key";
function log(message: string) {
console.log(new Date().toTimeString().slice(0, 8), message);
}
// Compare in constant time. timingSafeEqual always looks at every byte, so
// response timing does not reveal how much of the key was guessed correctly.
function checkKey(given: string): boolean {
const a = Buffer.from(given);
const b = Buffer.from(API_KEY);
// timingSafeEqual throws if the lengths differ, so check that first.
return a.length === b.length && timingSafeEqual(a, b);
}
const server = createServer((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;
}
// The key was right -- but a key proves only that the caller HAS the key,
// not that the caller is who we think. Anyone holding it looks identical.
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 {
const response = await fetch(PEER_URL, {
signal: AbortSignal.timeout(3000),
headers: { "X-API-Key": API_KEY }, // sent in the clear, every time
});
log(`--> go ${response.status} ${await response.text()}`);
} catch (error) {
log(`--> go unreachable: ${error}`);
}
}
}
server.listen(PORT, "127.0.0.1", () => {
console.log(`The TypeScript Server is running on port ${PORT} (api key required)`);
poll();
});Test it
Run both servers. Then, from a third terminal:
Without the key:
HTTP/1.1 401 Unauthorized
{"error":"unauthorized"}The stranger from Lesson 2 is blocked. That is genuine progress.
With the key:
{"service":"go","status":"ok"}Both services should keep logging (has key) every three seconds, so the link still works.
Read that second command once more before you close the terminal. You just authenticated as a service by copying a string out of a source file. Nothing about you was checked at all.
That observation is where Lesson 4 begins.
Common errors
| Error | Meaning | Fix |
|---|---|---|
Input buffers must have the same byte length | timingSafeEqual received different lengths | Check a.length === b.length first |
Always 401, even with the right key | You read req.headers["X-API-Key"] | Node lowercases header names: use "x-api-key" |
| Both a 401 and a 200 are sent | Missing return after the 401 branch | Add it |
Go: client.Get ignores my header | Get takes no headers | Use http.NewRequest plus client.Do |
Lesson 3 checkpoint
You are ready for Lesson 4 when you can explain all of these without rereading:
- what a header is and why
X-appears at the front of custom ones; - what a timing attack measures and why it works one character at a time;
- why
subtle.ConstantTimeCompareandtimingSafeEqualexist; - why the log now says
(has key)rather than the name of a service; - why a missing
returnafter a401is a security bug and not a style problem.
The endpoint is guarded and the strangers are gone. Next you will take the design apart and find five separate weaknesses, all of them caused by one property of the credential you just built.