28 β Authentication, Secrets and Local Mock Servers
Level: Intermediate Β· Time: ~30 min Β· Prerequisites: Lesson 27 β fetch in Practice
Why this matters
Lesson 27's request helper reached NASA POWER anonymously, because a public dataset never asks who is
calling. The moment the tracker talks to a vendor's cloud or your own backend, every request carries a
credential worth more than the data behind it β so credentials must never land in a log, never reach git,
and always be testable.
Three ways a credential travels
One rule covers all three of these: credentials travel in headers, never in the query string, where a
key is written into access logs, every proxy, the browser history and the Referer header. If a vendor
offers only ?api_key=, use it for reads over HTTPS and accept that the key is semi-public β never for a
write.
| Scheme | On the wire | Where it goes | Fits |
|---|---|---|---|
| API key | a long opaque string | a vendor-named header such as X-Api-Key |
machine-to-machine reads |
| Bearer token | Authorization: Bearer <token> |
the standard header | tokens that expire and can be revoked |
| Basic auth | Authorization: Basic <base64(user:password)> |
the standard header, HTTPS only | legacy services and token requests |
// src/api/authHeaders.ts
export function authHeaders(scheme: "bearer" | "basic", secret: string, user = ""): Record<string, string> {
const encoded = scheme === "basic" ? Buffer.from(`${user}:${secret}`).toString("base64") : secret;
return { Authorization: `${scheme === "basic" ? "Basic" : "Bearer"} ${encoded}` };
}
[!WARNING] Never log request headers and never put them in an error message:
console.error(headers)while debugging is how a live key ends up in a log aggregator that a dozen people can read.
Where a secret lives
In development a secret lives in an untracked .env β PVGIS_API_KEY=placeholder-key-for-local-dev,
INVERTER_TOKEN=placeholder-token-for-local-dev. The committed .env.example holds the same names with
no values, and in production there is no file at all: the platform injects the variables at run time.
# .gitignore β written before the first commit, not after the first incident
.env
.env.*
!.env.example
# and Node reads .env itself since 20.6: a real environment variable wins over the file
node --env-file=.env dist/index.js
Read the environment once, at startup, and fail fast when a secret is missing:
// src/config/secrets.ts
import { DomainError } from "../errors/domainError.js";
const apiKey = process.env.PVGIS_API_KEY;
if (apiKey === undefined || apiKey.trim() === "") throw new DomainError("CONFIG_INVALID", "PVGIS_API_KEY is required");
Lesson 23 does the same job with zod once the variables multiply; logError (Lesson 20) logs a code.
Never committed, and what to do when it happens
Deleting a file in a later commit does not delete it: git keeps every blob it ever stored, and clones, forks and CI caches keep their copies from the moment they exist.
git check-ignore -v .envconfirms the file is ignored and names the rule that does it.git log --all --diff-filter=A -- .envprints the commit that first added it: silence means it was never tracked.- The remedy is rotation: revoke the key at the vendor, issue a new one, deploy it. Scanners such as gitleaks catch the next leak in CI, but nothing un-leaks the old key.
OAuth2 in one page, and where to stop
Two grants cover almost everything: client credentials, where the app authenticates as itself with one POST to the token endpoint, and refresh token, where the household authorises the app once and the app exchanges a long-lived refresh token afterwards. What you should not build is the authorisation server β redirects, consent screens, PKCE, revocation β because a maintained library does that better.
// src/api/tokenClient.ts
let cached: { value: string; expiresAt: number } | undefined;
export async function getToken(base: string, clientId: string, secret: string): Promise<string> {
if (cached !== undefined && cached.expiresAt > Date.now()) return cached.value;
const basic = Buffer.from(`${clientId}:${secret}`).toString("base64");
const response = await fetch(`${base}/oauth/token`, { method: "POST", signal: AbortSignal.timeout(10_000),
headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: `Basic ${basic}` },
body: new URLSearchParams({ grant_type: "client_credentials", scope: "yield:read" }) });
if (!response.ok) throw new Error(`token request failed with ${response.status}`);
const payload = (await response.json()) as { access_token: string; expires_in: number }; // Lesson 30
cached = { value: payload.access_token, expiresAt: Date.now() + payload.expires_in * 1_000 - 30_000 };
return cached.value;
}
The token is fetched once and cached with a thirty-second margin before its expiry. A 401 means the token
was rejected: refresh once, then treat a second failure as configuration. Keep every credential
per-application and least-privileged β scoped to yield:read, one per environment.
A mock server, and tests that never touch the network
A live vendor API costs quota, needs a key on every laptop, and cannot produce a 429 on demand. A small
node:http server serving a fixture runs offline, answers instantly, and fails on request.
// src/mock/server.ts
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
const FIXTURE = JSON.stringify({ outputs: { daily: { "2026-08-01": { E_y: 4.21 } } } });
export async function startMockServer(requireToken: string): Promise<{ url: string; close: () => Promise<void> }> {
const server = createServer((request, response) => {
const mode = new URL(request.url ?? "/", "http://localhost").searchParams.get("mode");
if (request.headers.authorization !== `Bearer ${requireToken}`) {
response.writeHead(401).end(JSON.stringify({ error: "unauthorised" }));
return;
}
if (mode === "429") response.writeHead(429, { "Retry-After": "2" }).end();
else if (mode === "slow") setTimeout(() => { response.writeHead(200).end(FIXTURE); }, 5_000);
else response.writeHead(200, { "Content-Type": "application/json" }).end(FIXTURE);
});
await new Promise<void>((resolve) => { server.listen(0, "127.0.0.1", () => resolve()); });
const { port } = server.address() as AddressInfo;
return { url: `http://127.0.0.1:${port}`, close: () => new Promise<void>((r) => { server.close(() => r()); }) };
}
Port 0 asks the operating system for a free port, so tests never collide with anything running, and the
slow mode is the fixture Lesson 29 needs. The test passes an obviously fake token and asserts both
halves of the contract:
// test/ingest.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { startMockServer } from "../src/mock/server.js";
test("a wrong token is rejected and a good one is not", async () => {
const mock = await startMockServer("test-token");
const auth = { headers: { Authorization: "Bearer test-token" } };
try {
assert.equal((await fetch(`${mock.url}/api/v1/daily`)).status, 401);
assert.equal((await fetch(`${mock.url}/api/v1/daily`, auth)).status, 200);
} finally { await mock.close(); }
});
Run it with npx tsc && node --test dist/: no test here opens a socket to the internet, and the real key
never enters the repository. Lesson 42 makes the habit complete with Vitest.
Common mistakes
| Mistake | What happens | The fix |
|---|---|---|
| Putting a key in the query string | It is copied into access logs, proxies and Referer headers |
Send credentials in a header; query form for reads only |
Committing .env "temporarily" |
The key stays in history and in every clone, and scanners find it within minutes | .gitignore it before the first commit; rotate anything pushed |
Reading process.env.PVGIS_API_KEY deep inside the code |
A typo appears as undefined at the first call, in production |
Read and validate the environment once at startup |
| Logging headers while debugging | The secret lands in a dashboard several people can read | Log method, path, status and duration β never headers |
| Fetching a fresh OAuth token per request | Token endpoint rate limits and extra latency on every call | Cache the token until shortly before expires_in |
Try it
- Create
.env,.env.exampleand the.gitignorelines, then confirm the discipline:
Expected output: one line naming the .gitignore rule that matches .env, then nothing from the
second command, because the file was never tracked. If it prints a commit, rotate that key.
- Write
src/mock/server.tsandtest/ingest.test.tsas above, then runnpx tsc && node --test dist/.
Expected output: one passing test, and a summary containing # pass 1 and # fail 0.
- Start the mock from a script that prints its
urland keeps running, then call it three times: bare, with-H "Authorization: Bearer test-token", and with?mode=429.
Expected output: 401 Unauthorized with {"error":"unauthorised"}, then 200 OK with the fixture, then 429 Too Many Requests carrying Retry-After: 2.
Key takeaways
- A credential belongs in a header, never in a URL: query strings are logged by every proxy and browser.
- Secrets live in an untracked
.envin development and in the platform's environment in production. - A secret that has been pushed is a secret disclosed: rotation is the fix, not tidier history.
- Two OAuth2 grants plus a cached, least-privileged token cover API consumers; a mock server makes failures testable offline.
Check yourself
- Why is
?api_key=β¦in a URL worse than the same key in anX-Api-Keyheader, even over HTTPS? - A colleague deletes
.envin a new commit and pushes. Which keys are safe, and what must happen first? - Your token client caches for
expires_inminus thirty seconds. What breaks without the thirty? - The mock test asserts a
401for a wrong token. Name two things it cannot tell you about the real API.
Next
Lesson 29 β Resilience β Timeouts, Retries, Rate Limits and Pagination