Software Securityhome
Fix a findingSearch
Fix card · A07:2025 Authentication Failures

Fix hard-coded secrets in Node.js and Express

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Read the key from process.env at startup, where the deployment puts it from your secret store, and throw if it is missing or blank so the app never starts without it. Send it in the Authorization header, not the URL, return a generic 502 on partner errors, and keep .env files out of git.

On this page

Steps

  1. Find the keys: search the code and its history for string constants named like key, token, secret or password, for committed .env and config files, and for base64 strings that decode to credentials. Revoke and replace every key you find at the provider before anything else; deleting the commit does not unpublish it.
  2. Read each secret once, at startup, from process.env (this card's loadSettings), and throw with the variable's name when it is missing or blank. Do not fall back to a default value.
  3. Fill the variable at deploy time from the secret store, for example a Kubernetes Secret or a secret-manager client at startup. Never bake it into the image with a Dockerfile ENV or ARG line.
  4. For local development keep the values in a gitignored .env file and load it with node --env-file-if-exists=.env (Node 22.9 and later), not with code that ships. Commit a .env.example that lists the names with empty values.
  5. Send the key in the Authorization header, or the header the provider documents, never in the query string.
  6. On partner failures log the status or error code and return a generic 502; never return or log err.message from code that builds URLs with secrets in them.
  7. Add regression tests like this card's: scan the files that ship for the key, start the server without it and expect a refusal, and check the responses and logs during partner failures.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableimpl/vulnerable.jsJavaScript
const FORECAST_API_KEY = 'FAKE-forecast-key-for-tests-only';

export function loadSettings(env = process.env) {
  return { baseUrl: env.FORECAST_BASE_URL ?? 'https://api.forecast.example' };
}

With IMPL=vulnerable, 7 of 11 tests fail (4 exploit tests).

The vulnerable code: the key in the URL, the error and the log

Vulnerableimpl/vulnerable.jsJavaScript
export function forecastRoute({ baseUrl }, log) {
  return async (req, res) => {
    const city = typeof req.query.city === 'string' ? req.query.city.trim() : '';
    if (!city) return res.status(400).json({ error: 'city is required' });

    const url = `${baseUrl}/v1/forecast?city=${encodeURIComponent(city)}&api_key=${FORECAST_API_KEY}`;
    try {
      const partner = await fetch(url, { signal: AbortSignal.timeout(5000) });
      if (!partner.ok) throw new Error(`GET ${url} returned HTTP ${partner.status}`);
      const { summary, temperatureC } = await partner.json();
      res.json({ city, summary, temperatureC });
    } catch (err) {
      log.error(`forecast lookup failed: ${err.message}`);
      res.status(502).json({ error: err.message });
    }
  };
}

With IMPL=vulnerable, 7 of 11 tests fail (4 exploit tests).

The fix

Fixedimpl/fixed.jsJavaScript
export function loadSettings(env = process.env) {
  const apiKey = env.FORECAST_API_KEY?.trim();
  if (!apiKey) {
    // Name the missing setting, never a value.
    throw new Error('FORECAST_API_KEY is not set. Provide it from your secret store.');
  }
  return { apiKey, baseUrl: env.FORECAST_BASE_URL ?? 'https://api.forecast.example' };
}

With IMPL=fixed, 11 of 11 tests pass.

The fix: send the key in a header and keep errors generic

Fixedimpl/fixed.jsJavaScript
const UNAVAILABLE = 'The forecast service is unavailable. Try again later.';

export function forecastRoute({ apiKey, baseUrl }, log) {
  return async (req, res) => {
    const city = typeof req.query.city === 'string' ? req.query.city.trim() : '';
    if (!city) return res.status(400).json({ error: 'city is required' });

    const url = new URL('/v1/forecast', baseUrl);
    url.searchParams.set('city', city);
    try {
      const partner = await fetch(url, {
        headers: { Authorization: `Bearer ${apiKey}` },
        signal: AbortSignal.timeout(5000),
      });
      if (!partner.ok) {
        log.warn(`forecast partner returned HTTP ${partner.status}`);
        return res.status(502).json({ error: UNAVAILABLE });
      }
      const { summary, temperatureC } = await partner.json();
      res.json({ city, summary, temperatureC });
    } catch (err) {
      log.warn(`forecast partner unreachable: ${err.cause?.code ?? err.name}`);
      res.status(502).json({ error: UNAVAILABLE });
    }
  };
}

With IMPL=fixed, 11 of 11 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.jsJavaScript
loadEnvFile(new URL('./wrong.env', import.meta.url)); // committed "so the app runs out of the box"

export function loadSettings(env = process.env) {
  const apiKey = env.FORECAST_API_KEY?.trim();
  if (!apiKey) {
    throw new Error('FORECAST_API_KEY is not set. Provide it from your secret store.');
  }
  return { apiKey, baseUrl: env.FORECAST_BASE_URL ?? 'https://api.forecast.example' };
}

With IMPL=wrong, 2 of 11 tests fail (1 exploit test).

A fix that still fails: the .env file it loads is committed

Wrong fiximpl/wrong.envDotenv
# Committed next to the code "so the app runs out of the box". Everyone with the repository,
# a fork, a CI log of the checkout or the container image now has the partner's key.
FORECAST_API_KEY=FAKE-forecast-key-for-tests-only

With IMPL=wrong, 2 of 11 tests fail (1 exploit test).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtest/secrets.test.jsJavaScript
describe(`exploit (IMPL=${IMPL})`, () => {
  test('the key is not in any file that ships with the app', () => {
    const files = shippedFiles();
    assert.ok(files.includes(join(ROOT, 'impl', `${IMPL}.js`)), 'the scan must cover the implementation');
    const leaks = files.filter((file) => revealsKey(readFileSync(file, 'latin1'))).map((file) => relative(ROOT, file));
    assert.deepEqual(leaks, [], `the key can be read from: ${leaks.join(', ')}`);
  });

  test('the key is not sent in the URL, where proxies and access logs keep it', async () => {
    await get(app, '/forecast?city=Oslo');
    assert.equal(partner.requests.length, 1, 'the app did not call the partner API');
    const url = decodeURIComponent(partner.requests[0].url);
    assert.ok(!revealsKey(url), `the key is in the request URL: ${redact(url)}`);
  });

  test('a partner error does not put the key in the response', async () => {
    partner.outage = true;
    const res = await get(app, '/forecast?city=Oslo');
    assert.ok(!revealsKey(res.text), `the ${res.status} response contains the key: ${redact(res.text)}`);
  });

  test('an unreachable partner does not put the key in the response', async () => {
    const down = await startApp({ FORECAST_API_KEY: KEY, FORECAST_BASE_URL: await unreachableUrl() });
    try {
      const res = await get(down, '/forecast?city=Oslo');
      assert.equal(res.status, 502);
      assert.ok(!revealsKey(res.text), `the 502 response contains the key: ${redact(res.text)}`);
    } finally {
      down.close();
    }
  });

  test('the key is never written to the logs', async () => {
    const up = await startApp({ FORECAST_API_KEY: KEY, FORECAST_BASE_URL: partner.url });
    const down = await startApp({ FORECAST_API_KEY: KEY, FORECAST_BASE_URL: await unreachableUrl() });
    try {
      await get(up, '/forecast?city=Oslo');
      partner.outage = true;
      await get(up, '/forecast?city=Oslo');
      await get(down, '/forecast?city=Oslo');
    } finally {
      up.close();
      down.close();
    }
    const logs = [...up.logs, ...down.logs].join('\n');
    assert.ok(!revealsKey(logs), `the logs contain the key:\n${redact(logs)}`);
  });
});

Pitfalls

  • A committed .env file is a hard-coded secret. process.loadEnvFile() and dotenv load it silently, so the app keeps working with no configuration at all. dotenv's own FAQ answers "Should I commit my .env file?" with "No."
  • process.loadEnvFile() and --env-file never override a variable that is already set, so a deployment that sets FORECAST_API_KEY hides the committed file. The key in the repository is still readable by everyone who has it.
  • A fallback such as process.env.FORECAST_API_KEY || 'dev-key' puts the hard-coded secret back and defeats the startup check.
  • Base64 is an encoding, not encryption: Buffer.from(encoded, 'base64') in the source still contains the key.
  • Errors that include the request URL, such as new Error(GET ${url} returned ${status}), carry the key when it is in the query string, and Express handlers often send err.message straight to the client.
  • fetch drops the Authorization header when a redirect leads to another origin, but forwards custom headers such as X-Api-Key (checked on Node 26.11.1).
  • Child processes inherit the whole environment: the env option of child_process.spawn() defaults to process.env. Pass a filtered environment to anything that does not need the key.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs hardcoded-secrets/node-express does this for you).
  2. Start the service without the key, for example env -u FORECAST_API_KEY PORT=0 node server.js, and expect it to exit with "Refusing to start: FORECAST_API_KEY is not set".
  3. Scan the working tree with a secret scanner, for example detect-secrets scan --all-files, and the git history with a scanner that reads it, such as gitleaks or TruffleHog; treat every hit as a key to rotate. The scanners checked for this card did not decode base64, so also review long encoded strings next to key-like names.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix hard-coded secrets in Node.js and Express
ToolRuleWhat it found here
Semgrepgeneric.secrets.security.detected-generic-api-key.detected-generic-api-keyNo findings on this card: the rule wants 32 to 45 letters and digits after a name like api_key, and the card's fake key contains hyphens. On a probe copy of impl/vulnerable.js and impl/wrong.env with a random 32-character key it flagged both.Checked: Semgrep 1.180.0 (CE)
Semgrepgeneric.secrets.gitleaks.generic-api-key.generic-api-keyNo findings on this card, and none on the probe copy with a random key either. The Semgrep port drops any match on a line that contains an upper-case name ending in _KEY, such as FORECAST_API_KEY, and its stopword list (which includes fake and test) excludes the card's key.Checked: Semgrep 1.180.0 (CE)
CodeQLjs/hardcoded-credentialsNot expected to report the vulnerable code. The query follows non-empty string constants, through concatenation, Buffer.from and base64 helpers, into credential sinks: parameters of modelled libraries and values written to an Authorization header (frameworks/ClientRequests.qll). A key appended to a query string reaches no sink. The same constant sent as Authorization: Bearer would be reported.Checked: github/codeql main at b5b8165; not executed
CodeQLjs/clear-text-loggingUncertain. Its sources include reads of variables named like an API key (the shared heuristic matches api.?(key|tok)), but the card logs through an injected log object, and whether the query treats that as a logger, and follows the key through new Error(...).message, was not established.Checked: github/codeql main at b5b8165; not executed
detect-secrets (Yelp)KeywordDetectorSecret KeywordFlagged impl/vulnerable.js line 7 and impl/wrong.env line 4, even with the card's fake key: the detector keys on the variable name. Nothing in impl/fixed.js.Checked: detect-secrets 1.5.0

Libraries and versions

Libraries and versions
ComponentVersion
LanguageJavaScript (ES modules)
RuntimeNode.js 26.11.1
FrameworkExpress 5.3.0
express5.3.0

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the hardcoded-secrets/node-expressfolder.

Shell
npm ci
Shell
# Expected: every test passes
IMPL=fixed npm test --silent

# Expected: an exploit test fails
IMPL=vulnerable npm test --silent

# Expected: a test fails
IMPL=wrong npm test --silent

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs hardcoded-secrets/node-express runs all three modes and checks the expected results.

Download the project ZIP, 29 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • node 26.11.1
  • npm 11.20.0
  • express 5.3.0
Matrix
fixed: 11 of 11 tests pass; vulnerable: 7 of 11 tests fail (4 exploit tests); wrong: 2 of 11 tests fail (1 exploit test)
Test results
fixed 11/11 pass; vulnerable 7/11 fail (4 of the 5 exploit tests); wrong 2/11 fail (1 exploit test), offline after npm ci
Dependency freshness
express 5.3.0 was published on 2026-10-09; it is taken at one day old because it is a security release (it upgrades qs for CVE-2026-2391, CVE-2026-82417 and CVE-2026-82562, and proxy-addr for CVE-2026-90711). npm audit found 0 vulnerabilities and npm audit signatures verified all 65 packages. Rerun on Node.js 26.11.1, above the 26.5.1 security floor in engines.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP Secrets Management Cheat Sheet
  2. Node.js: process.loadEnvFile(path)
  3. Node.js: --env-file and --env-file-if-exists
  4. dotenv FAQ: Should I commit my .env file?
  5. Node.js: child_process.spawn() options (env defaults to process.env)
  6. The Twelve-Factor App: III. Config
  7. GitHub Docs: Removing sensitive data from a repository

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.