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
0against the real server and four findings and1against 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.