Skip to content

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 .env confirms the file is ignored and names the rule that does it.
  • git log --all --diff-filter=A -- .env prints 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

  1. Create .env, .env.example and the .gitignore lines, then confirm the discipline:
git check-ignore -v .env
git log --all --diff-filter=A -- .env

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.

  1. Write src/mock/server.ts and test/ingest.test.ts as above, then run npx tsc && node --test dist/.

Expected output: one passing test, and a summary containing # pass 1 and # fail 0.

  1. Start the mock from a script that prints its url and 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 .env in 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

  1. Why is ?api_key=… in a URL worse than the same key in an X-Api-Key header, even over HTTPS?
  2. A colleague deletes .env in a new commit and pushes. Which keys are safe, and what must happen first?
  3. Your token client caches for expires_in minus thirty seconds. What breaks without the thirty?
  4. The mock test asserts a 401 for a wrong token. Name two things it cannot tell you about the real API.

Next

Lesson 29 β€” Resilience β€” Timeouts, Retries, Rate Limits and Pagination