Secrets and Dependencies

Read secrets from the environment, validate them at startup, and keep your dependency tree from becoming the attack.

Syntaxnode --env-file=.env server.js

A secret is any value that grants access: a database password, an API token, a signing key. Two things get services breached more often than clever attacks. A secret committed to a repository, and a dependency nobody chose.

Secrets come from the environment

Environment variables are name-and-value pairs given to a process when it starts, readable as process.env. They are the standard place for secrets because they live with the deployment, not with the code: the same image runs in staging and production with different values and no file to forget to exclude from git. Read them once at startup, validate that the required ones are present, and exit with a clear message if they are not. A process that starts without its database password and fails on the first request is harder to diagnose than one that refuses to start.

For local development Node can load a file for you: node --env-file=.env server.js (added v20.6.0, stable in v24.10.0), --env-file-if-exists (v22.9.0), or process.loadEnvFile() in code (v20.12.0). Add .env to .gitignore the moment you create it.

Your dependencies are your code

package-lock.json records the exact version and integrity hash of every package, direct and transitive. Commit it, and install with npm ci in CI and in your image build: it installs exactly what the lockfile says and fails if the lockfile and package.json disagree. npm audit lists known advisories against what you have installed. Every package can also run install scripts on your machine, which is why a compromised package is a problem before you ever import it.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Move a database password out of a committed config file and into the deployment's environment.
  • Make a service fail at start-up with a clear message when a required variable is missing.
  • Decide whether a high-severity npm audit finding affects what actually runs in production.
  • Rotate a leaked API token while existing clients are still using the old one.

More examples

One config module, validated and frozen

Reading process.env all over the codebase means a typo'd variable name becomes undefined at the moment it is used, usually in production. One module that validates at import time turns that into a start-up failure with a message. Freezing it stops later code quietly rewriting a value.

Example · javascript
// config.js - the only file in the project that reads process.env.
const required = ['DATABASE_URL', 'SESSION_SECRET'];
const missing = required.filter((name) => !process.env[name]);

if (missing.length > 0) {
  // Name what is missing, then stop. A non-zero exit code tells the
  // container runtime the start failed.
  console.error(`missing environment variables: ${missing.join(', ')}`);
  process.exit(1);
}

module.exports = Object.freeze({
  port: Number(process.env.PORT ?? 3000),
  databaseUrl: process.env.DATABASE_URL,
  sessionSecret: process.env.SESSION_SECRET,
  // Env values are strings, and every non-empty string is truthy:
  // DEBUG='false' would be true. Compare against the text you expect.
  debug: process.env.DEBUG === 'true'
});

The dependency commands worth putting in CI

These are the commands behind the phrase supply-chain risk. Run npm ci rather than npm install anywhere reproducibility matters, gate the build on npm audit at a severity you can live with, and look at the tree size before adding a package that saves ten lines.

Example · bash
# Install exactly what the lockfile says. Fails if package.json and
# package-lock.json disagree, which is what you want in CI and in a build.
npm ci

# Known advisories against the installed tree. --omit=dev leaves out
# build-time packages, so what is left is what actually runs in production.
npm audit --omit=dev --audit-level=high

# What a package can run on install: preinstall, install, postinstall.
# This is how a compromised package acts before any of your code imports it.
npm ci --ignore-scripts        # then run the few builds you do need by hand

# See the whole tree, not just what you asked for. The transitive packages
# you never chose are the part worth looking at.
npm ls --all | wc -l
npm ls some-package            # who pulled this in, and at what version

# Keep the secrets file out of the repository from the first commit.
echo '.env' >> .gitignore

Rotating a secret without dropping requests

A secret that cannot be changed without downtime does not get changed, including after someone leaves or a token leaks. Accepting the current and previous value for a window makes rotation a configuration change instead of an outage, and it is the same shape you need for signing keys.

Example · javascript
const { timingSafeEqual } = require('node:crypto');

// During a rotation both values are set in the environment. Clients move
// to the new token at their own pace, then the previous variable is removed.
const accepted = [process.env.API_TOKEN, process.env.API_TOKEN_PREVIOUS]
  .filter(Boolean)                      // drop the unset one
  .map((token) => Buffer.from(token));

function tokenMatches(given) {
  const candidate = Buffer.from(given);
  // Compare against every accepted token with no early exit. .some() stops
  // at the first predicate that returns true, so a match on the current
  // token would return sooner than a match on the previous one, and the
  // time taken would say which one matched. The length guard keeps
  // timingSafeEqual from throwing on a different-length input.
  let matched = false;
  for (const known of accepted) {
    if (candidate.length === known.length && timingSafeEqual(candidate, known)) {
      matched = true;                   // keep going; do not return here
    }
  }
  return matched;
}

// Log the fact, never the secret. A hash prefix is enough to tell two
// tokens apart in a log without putting either one in it.
if (!tokenMatches(header)) {
  log.warn({ event: 'auth_failed', tokenLength: header.length });
}

Discussion

  • Be the first to comment on this lesson.