Skip to content
academia.sh

Lesson 09 / 16

Separating Secrets

The boundary between configuration and a secret is drawn by exit paths; two-layer redaction based on the key name and on the value itself is applied, internal detail is kept out of the client while staying in the log, and a leak audit catches an unredacted field through its exit code.

Contents

One variable was deliberately missing from the previous lesson’s schema: the store’s password. The port and the password are read from the same place, the environment, and carried in the same object; even so, one can be written to the log at startup and the other cannot. This lesson draws that line and shows where in the code it is enforced.

The subject is not hiding a secret value — the value already sits in process memory, and the application has to use it. The subject is keeping that value from reaching the process’s exit paths: log lines, error responses, bodies returned to the client, and headers.

Where the Boundary Sits

The test that separates configuration from a secret is not what the value is, it is what happens once it is exposed. A port number showing up in the log is not a problem; a password showing up means anyone who can read the log can reach the store.

This test produces three practical rules:

  • A secret is not written into code. A value that lands in a file under version control has entered every copy of that repository and its entire history. It cannot be scrubbed from history once it needs to change.
  • A secret is not written into the log. Logs get collected, moved around, and are usually visible to a wider audience than the application itself.
  • A secret does not reach the client. Error responses, settings views, and diagnostic output are the places this rule breaks most often.

Rules are not enough as intent; they have to be wired into code. The two modules below do that.

Two-Layer Redaction

Redaction replaces a value with a fixed mark before it is written to output. There are two ways to apply it, and each is incomplete on its own.

The first looks at the key name: if the field name contains a word like password, key, or token, redact its value. This approach is declarative and readable, but it misses a secret landing in an unexpected field — inside the text of an error message, for instance.

The second looks at the value itself: if any of the secret values registered at startup turns up, whatever field it is in, replace it. This approach closes off unexpected paths, but it only recognizes values that were registered at startup.

The two are applied together.

// src/setup/secrets.mjs — registers secret values; redacts them on log and response paths
export const MASK = "***";

const SECRET_VALUES = new Set();
const SECRET_KEY = /password|key|token|secret|authorization|cookie/i;

// Registering every secret once at startup is what value-based redaction relies on.
export const registerSecret = (value) => {
  if (typeof value === "string" && value.length >= 6) SECRET_VALUES.add(value);
};

export const redact = (value, key = "") => {
  if (typeof value === "string") {
    if (SECRET_KEY.test(key)) return MASK;                       // 1. by key name
    let result = value;
    for (const secret of SECRET_VALUES) result = result.split(secret).join(MASK);
    return result;                                                // 2. by the value itself
  }
  if (Array.isArray(value)) return value.map((e) => redact(e));
  if (value instanceof Error) {
    return { name: value.name, message: redact(value.message), code: value.code ?? null };
  }
  if (value && typeof value === "object") {
    return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redact(v, k)]));
  }
  return value;
};

Error objects have to be handled separately, because JSON.stringify turns an Error instance into an empty object; the message and stack trace disappear. The conversion here keeps the message but sends it through redaction first.

The configuration reader registers secret fields at the same time it reads them. This single line is what value-based redaction depends on.

// src/setup/settings.mjs — reads from the environment; flags secret fields and registers them with secrets
import { registerSecret } from "./secrets.mjs";

const SCHEMA = {
  PORT: { default: "8436" },
  STORE_ADDRESS: { default: "store://[email protected]:5432/loans" },
  STORE_PASSWORD: { required: true, secret: true },
  SIGNING_KEY: { required: true, secret: true },
};

export const readSettings = (env) => {
  const settings = {}, missing = [];
  for (const [name, rule] of Object.entries(SCHEMA)) {
    const value = env["LIBRARY_" + name] ?? rule.default;
    if (value === undefined) { missing.push("LIBRARY_" + name); continue; }
    if (rule.secret) registerSecret(value);
    settings[name] = value;
  }
  if (missing.length > 0) {
    console.error("configuration invalid: " + missing.join(", "));
    process.exit(78);
  }
  return Object.freeze(settings);
};

The secret flag in the schema is the only field added to the previous lesson’s schema. The distinction is declared in the schema; its enforcement is gathered into a single place.

Closing the Exit Paths

The server’s rule is this: every object heading to the log or the response passes through redaction. This is enforced by defining two functions in one spot; no direct write happens anywhere else.

// src/http/server.mjs — every exit path goes through redact: log, error response, and settings view
import { createServer } from "node:http";
import { readSettings } from "../setup/settings.mjs";
import { redact } from "../setup/secrets.mjs";

const settings = readSettings(process.env);
let counter = 0;

const log = (record) => console.log(JSON.stringify(redact(record)));

const connect = () => {                       // store connection attempt: error message carries the address
  const error = new Error(
    `could not connect to the store: ${settings.STORE_ADDRESS} (password: ${settings.STORE_PASSWORD})`);
  error.code = "store_unreachable";
  throw error;
};

createServer((req, res) => {
  res.sendDate = false;
  const id = `r-${++counter}`;
  const path = new URL(req.url, "http://local").pathname;
  const write = (status, body) => {
    const text = JSON.stringify(redact(body));
    res.writeHead(status, { "content-type": "application/json; charset=utf-8",
      "content-length": Buffer.byteLength(text), "x-request-id": id });
    res.end(text);
  };

  try {
    if (path === "/health") return write(200, { status: "up" });
    if (path === "/settings") return write(200, { settings });          // secret fields are redacted
    if (path === "/loans") { connect(); return; }
    write(404, { error: "path_not_found", requestId: id });
  } catch (error) {
    log({ level: "error", requestId: id, path, error });   // internal detail stays only in the log
    write(500, { error: "internal_error", requestId: id }); // client gets only the code
  }
}).listen(Number(settings.PORT), "127.0.0.1",
  () => log({ level: "info", event: "started", settings }));

The connect function stands in for a real failure: error messages produced by store-connecting layers often carry the connection address and sometimes the credential too. If this message were written to the log without passing through redaction, the secret value would stay there.

The same catch block sets up a second boundary as well: internal detail goes to the log, the client gets only an error code and a request id. Having the id on both sides means a member asking for support can hand over a number that leads straight back to the log line, without the detail itself ever reaching the response.

The Leak Audit

Whether the rules above actually hold is verified by running, not by reading. The program below opens a server with known secret values, sends requests to its endpoints, collects the response bodies, the response headers, and everything the process writes to its log, and then searches those texts for the secret values.

// audit/leak.mjs — opens the given server with known secret values; tests whether those values
// land in the response body, response headers, or the log. Exits with 1 if it finds any.
// Usage: node audit/leak.mjs <script> <port>
import { spawn } from "node:child_process";
import { setTimeout as wait } from "node:timers/promises";

const SECRETS = {
  LIBRARY_STORE_PASSWORD: "shelf-password-9f3a2b4c",
  LIBRARY_SIGNING_KEY: "signing-key-7c1d8e5a",
};
const ENDPOINTS = ["/health", "/settings", "/loans"];

const [script, port] = process.argv.slice(2);

const child = spawn("node", [script], {
  env: { ...process.env, ...SECRETS, LIBRARY_PORT: port },
});
let log = "";
child.stdout.on("data", (p) => (log += p));
child.stderr.on("data", (p) => (log += p));
await wait(800);

const findings = [];
console.log(`### ${script} — responses returned to the client`);
for (const path of ENDPOINTS) {
  const res = await fetch(`http://127.0.0.1:${port}${path}`);
  const body = await res.text();
  const headers = [...res.headers].map(([k, v]) => `${k}: ${v}`).join("\n");
  console.log(`  ${res.status} ${path}  ${body}`);
  for (const [name, value] of Object.entries(SECRETS)) {
    if (body.includes(value)) findings.push(`${path} response body <- ${name}`);
    if (headers.includes(value)) findings.push(`${path} response headers <- ${name}`);
  }
}

await wait(200);
child.kill();
await wait(300);
for (const [name, value] of Object.entries(SECRETS)) {
  if (log.includes(value)) findings.push(`log <- ${name}`);
}

console.log("--- log ---");
console.log(log.trimEnd().split("\n").map((s) => "  " + s).join("\n"));
console.log("--- audit ---");
for (const finding of findings) console.log(`  LEAK  ${finding}`);
console.log(findings.length === 0
  ? "  no secret value found on any exit path"
  : `  ${findings.length} leaks found`);
process.exit(findings.length === 0 ? 0 : 1);

An audit that finds no leak can stay silent for two different reasons: there genuinely is no leak, or the audit itself is broken. Telling the two apart requires also running the audit against an example deliberately left flawed. The file below sits in the audit/ directory for that purpose — it is outside the source tree and is never called from any of the application’s paths.

// audit/flawed-example.mjs — AUDIT EXAMPLE, not production code.
// Mimics a version with no redaction applied; exists to prove the leak audit
// actually catches it, and sits outside the source tree.
import { createServer } from "node:http";
import { readSettings } from "../src/setup/settings.mjs";

const settings = readSettings(process.env);

createServer((req, res) => {
  res.sendDate = false;
  const path = new URL(req.url, "http://local").pathname;
  const body = path === "/settings" ? { settings } : { status: "up" };
  const text = JSON.stringify(body);
  res.writeHead(200, { "content-type": "application/json; charset=utf-8",
    "content-length": Buffer.byteLength(text) });
  res.end(text);
}).listen(Number(settings.PORT), "127.0.0.1",
  () => console.log(JSON.stringify({ event: "started", settings })));
#!/usr/bin/env bash
# Runs the leak audit first against the real server, then against the deliberately flawed example.
node audit/leak.mjs src/http/server.mjs 8436; echo "exit code=$?"
echo
node audit/leak.mjs audit/flawed-example.mjs 8437; echo "exit code=$?"
### src/http/server.mjs — responses returned to the client
  200 /health  {"status":"up"}
  200 /settings  {"settings":{"PORT":"8436","STORE_ADDRESS":"store://[email protected]:5432/loans","STORE_PASSWORD":"***","SIGNING_KEY":"***"}}
  500 /loans  {"error":"internal_error","requestId":"r-3"}
--- log ---
  {"level":"info","event":"started","settings":{"PORT":"8436","STORE_ADDRESS":"store://[email protected]:5432/loans","STORE_PASSWORD":"***","SIGNING_KEY":"***"}}
  {"level":"error","requestId":"r-3","path":"/loans","error":{"name":"Error","message":"could not connect to the store: store://[email protected]:5432/loans (password: ***)","code":"store_unreachable"}}
--- audit ---
  no secret value found on any exit path
exit code=0

### audit/flawed-example.mjs — responses returned to the client
  200 /health  {"status":"up"}
  200 /settings  {"settings":{"PORT":"8437","STORE_ADDRESS":"store://[email protected]:5432/loans","STORE_PASSWORD":"shelf-password-9f3a2b4c","SIGNING_KEY":"signing-key-7c1d8e5a"}}
  200 /loans  {"status":"up"}
--- log ---
  {"event":"started","settings":{"PORT":"8437","STORE_ADDRESS":"store://[email protected]:5432/loans","STORE_PASSWORD":"shelf-password-9f3a2b4c","SIGNING_KEY":"signing-key-7c1d8e5a"}}
--- audit ---
  LEAK  /settings response body <- LIBRARY_STORE_PASSWORD
  LEAK  /settings response body <- LIBRARY_SIGNING_KEY
  LEAK  log <- LIBRARY_STORE_PASSWORD
  LEAK  log <- LIBRARY_SIGNING_KEY
  4 leaks found
exit code=1

The most instructive line in the output is the error record in the log. The message reading password: ***, even though no field there is named “password,” has been redacted. The secret value sits embedded in the middle of an error message; the layer that catches it is the second one, the one working off values registered at startup. Redaction that only looked at key names would have written this line through unchanged.

The /settings response shows the second boundary: STORE_ADDRESS in the same object is visible, while the two secret fields become ***. Having a settings view at all is not a problem; having which fields show up defined in the schema is what makes it safe.

The /loans response gives the third. The client got {"error":"internal_error","requestId":"r-3"}; the store’s address, why the connection failed, and the error’s internal code are absent from the response. The log line for that same request carries all of it, and the two records line up by r-3.

Running against the flawed example proves the audit does not produce an empty pass by default: four separate exit points were reported by name, and the exit code was 1. Wired to a build step, this code stops a field whose redaction was forgotten from reaching release.

The Cost of Change

Redaction alone is not enough, because the only remedy for an exposed secret is to rotate it. The application has to make that cheap: if a value is read only from the environment, rotating it is nothing more than updating the environment and restarting the processes. If the same value is baked into the code or copied to more than one place, rotation turns into a search.

The measurable form of this is that the number of places in the application where a secret is read has to be countable. In the scaffold above, that number is one — the readSettings function. Every piece of code that uses a value gets it from the settings object; none of them looks at process.env on its own.

Summary

  • The test separating configuration from a secret is the consequence of exposure; a secret is not written into code, not written into the log, and does not reach the client.
  • Redaction is two-layered: the layer keyed on field name is declarative but incomplete, the layer keyed on values registered at startup closes off unexpected fields; in the measurement, the second layer caught a password embedded in an error message.
  • Error objects are handled separately, because serializing them directly loses the message; the conversion here keeps the message and sends it through redaction.
  • Internal detail stays in the log, the client gets only an error code and a request id; because the same id appears in both, they can be matched up afterward.
  • The leak audit opens the server with known secret values and scans the responses and the log; it produced 0 against the real server and four findings and 1 against the deliberately flawed example.

Next Step

This lesson’s log lines were in JSON, and each one carried a request id, but that id was only written by hand in two places. Once a request passes through five chain links and three modules, how does every line end up carrying the same id? What is the level threshold for, and what single query gathers every line belonging to one request afterward? The next lesson sets up structured logging: it carries the request context through the chain and gathers every record of a single request with one command.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close