---
title: 'Separating Secrets'
source: 'https://academia.sh/en/courses/server-fundamentals/separating-secrets'
course: 'Server-Side Fundamentals'
language: en
updated: '2026-08-19T05:19:37+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.

```js
// 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://library@10.0.0.7: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.

```js
// 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.

```js
// 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.

```js
// 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 })));
```

```bash
#!/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://library@10.0.0.7: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://library@10.0.0.7:5432/loans","STORE_PASSWORD":"***","SIGNING_KEY":"***"}}
  {"level":"error","requestId":"r-3","path":"/loans","error":{"name":"Error","message":"could not connect to the store: store://library@10.0.0.7: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://library@10.0.0.7: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://library@10.0.0.7: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.
