Course outline · 0% complete

0/29 lessons0%

Course overview →

Config from the Environment

lesson 8-1 · ~11 min · 24/29

Why a secret cannot be a const in a committed file

Because anyone with repo access can read it, forever, including through git history, and use it to mint valid tokens.

A leaked signing secret means anyone can forge tokens for any user, so it is a total compromise of authentication rather than a partial one. Nothing in the logs distinguishes a forged token from a real one, since both verify correctly.

Git history preserves secrets even after you delete them, which is the part people underestimate. A secret committed on Monday and removed on Tuesday is still in every clone anyone made in between, and still recoverable from the repository afterward.

The fix is the environment, where the process receives secrets from outside at startup. This lesson makes that pattern concrete, including how to fail loudly when a required value is missing.

One codebase, many environments

The same code runs on your laptop, on a test server, and in production, but with different ports, database URLs, and secrets. Hardcoding any of them breaks the others.

The convention: configuration comes from environment variables, the key-value pairs every process receives from its parent (lesson 2-3 introduced process.env).

PORT=4000 JWT_SECRET=k3... node server.js
const port = Number(process.env.PORT || 3000);

Locally, a .env file (loaded by the dotenv package or node --env-file) holds these, and .env goes in .gitignore, the list of paths git is told never to record (lesson 7-4 explained why a secret that reaches git history is unrecoverable). In production, your hosting platform injects them.

Two habits separate clean config from chaos: defaults for harmless settings (port, log level), and fail fast at startup for required secrets, because a server that boots without its JWT secret will only reveal the problem when logins mysteriously fail at 2 a.m.

Loading and validating config

loadConfig(env) refuses to proceed without a JWT secret and supplies defaults for everything harmless.

function loadConfig(env) {
  if (!env.JWT_SECRET) {
    return { ok: false, error: "missing required config: JWT_SECRET" };
  }
  return {
    ok: true,
    config: {
      port: Number(env.PORT || 3000),
      logLevel: env.LOG_LEVEL || "info",
      jwtSecret: env.JWT_SECRET,
    },
  };
}

console.log(JSON.stringify(loadConfig({ JWT_SECRET: "k3y", PORT: "4000" })));
console.log(JSON.stringify(loadConfig({ JWT_SECRET: "k3y" })));
console.log(JSON.stringify(loadConfig({ PORT: "4000" })));

Output

{"ok":true,"config":{"port":4000,"logLevel":"info","jwtSecret":"k3y"}}
{"ok":true,"config":{"port":3000,"logLevel":"info","jwtSecret":"k3y"}}
{"ok":false,"error":"missing required config: JWT_SECRET"}

The required secret is checked first with an early return, which is the fail-fast pattern. Everything after that line can assume the secret exists, so no later code needs to wonder.

env.PORT || 3000 falls back when PORT is undefined, and Number(...) converts the string "4000", because environment variables are always strings. That is the same string-versus-number discipline as query parameters, arriving from a different direction.

The || fallback has one sharp edge worth knowing. PORT=0 would be falsy and fall back to 3000, and while port 0 is unusual it is meaningful, since it asks the OS for a free port, and ?? would handle that case correctly.

The { ok, config } and { ok, error } shape is the same result object as safeParse in lesson 6-1. One convention for reporting failure across a codebase is worth more than each function choosing its own.

Note the split between required and optional. A missing secret is fatal because no sensible default exists, and a missing port or log level is harmless because 3000 and "info" are reasonable everywhere, and deciding which is which is the actual design work here.

The core of the dotenv package

parseEnvFile(text) reads one KEY=VALUE per line, skipping blanks and comments, and splits at the first = only so values may contain their own equals signs.

function parseEnvFile(text) {
  const env = {};
  for (const line of text.split("\n")) {
    const trimmed = line.trim();
    if (trimmed === "" || trimmed.startsWith("#")) continue;
    const i = trimmed.indexOf("=");
    env[trimmed.slice(0, i)] = trimmed.slice(i + 1);
  }
  return env;
}

const file = "# local settings\nPORT=4000\n\nJWT_SECRET=k3y=with=equals";
console.log(JSON.stringify(parseEnvFile(file)));

Output

{"PORT":"4000","JWT_SECRET":"k3y=with=equals"}

The skip line is if (trimmed === "" || trimmed.startsWith("#")) continue;, which handles both blank separators and comments in one condition. Trimming first means an indented comment is still recognized as one.

indexOf("=") finds only the first separator, so trimmed.slice(0, i) is the key and trimmed.slice(i + 1) is the value. Using split("=") instead would chop the JWT_SECRET value apart at its inner equals signs, and base64 secrets end in = padding constantly, so this is a real failure rather than a contrived one.

This is the first-separator trick from lesson 1-3's header parser, applied to a different format. Recognizing the same shape twice is the point, since key-value text formats almost always need it.

The real dotenv does more, and knowing what it adds prevents surprises. It strips surrounding quotes, understands \n escapes inside quoted values, and refuses to overwrite variables already present in process.env, which is why a shell-provided value wins over the file.

Note that Node now has --env-file built in, so a dependency is no longer required for this. The parsing rules are the same, which is exactly why writing the parser once is worth the ten minutes.

Why missing config should stop the boot

Because a boot-time crash is caught immediately by whoever deployed it, and a request-time failure surfaces later as mysterious breakage for users.

Fail fast means that if required config is missing, the deploy should visibly fail while a human is watching. The person who caused the problem is present, the change is fresh, and the fix takes a minute.

The alternative is a server that seems healthy and breaks on real traffic, which is strictly worse. Health checks pass, the process stays up, and only the login endpoint fails, so the monitoring says green while users cannot get in.

The timing makes it worse still. The first login might be minutes or hours after the deploy, by which time the deploy is no longer the obvious suspect, and someone is debugging authentication at 2 a.m. for a missing environment variable.

Validate all required config once, at boot, and refuse to start without it. Validating everything at once also matters, since reporting the first missing variable and exiting means three missing variables take three deploys to discover.