Whichever secrets store you pick — a cloud provider's secret manager, Doppler, 1Password, Vault, or encrypted files in git — almost all of them hand your process the same thing at boot: a flat blob of environment variables. That means the vendor decision governs storage, distribution, and rotation, but it barely touches the path most small-team secret leaks actually take, which is a secret getting printed: into a log line, an error report, a subprocess, or a CI debug step. A commenter on an earlier post made exactly this point, and it's the more useful half of the problem.
This post is about what you do after the vendor choice. It's Node-flavored, but the leak paths are language-agnostic.
Why does the store barely change the leak path?
Every injector converges. doppler run -- node server.js, op run -- node server.js, Vault Agent templating a file, Kubernetes envFrom.secretRef, or a plain .env — by the time your first line of code executes, the secret is a string in process.env, readable by anything in the process and by anything that can serialize an object containing it.
So the threat model shifts. Storage-layer questions ("who can read the secret in the vault?") are answered by the vendor. Runtime questions are yours alone:
| Leak path | Who ends up seeing it | Does the vendor help? |
|---|---|---|
console.log(config) in a debug session |
Anyone with log access, forever | No |
| Error reporter attaching request/config context | Your SaaS error tracker's operators | No |
child_process.spawn inheriting the full env |
Any script you shell out to, and its logs | No |
/proc/<pid>/environ on the box |
Any process running as the same user | No |
kubectl describe pod with literal env values |
Anyone with read access to the namespace | Partly — secretKeyRef shows the reference, not the value |
| CI job echoing a derived value | Anyone who can read build logs | Partly — masking only catches exact matches |
Two of those deserve a note. On Linux, a process's environment is readable at /proc/<pid>/environ by processes running as the same user, so "it's only in memory" is weaker than it sounds on a shared host. And in Kubernetes, env vars sourced with secretKeyRef show up in kubectl describe pod as a reference rather than a value — but env vars set as literals in a manifest are printed in full.
The vendor decides who can fetch the secret; your code decides how many places it can be printed.
How do you stop a secret from ever printing?
Make the secret a type that cannot serialize, instead of a string you promise never to log. In Node, three hooks cover essentially every accidental print: toString, toJSON, and the custom inspect symbol.
// secret.js
const REDACTED = "[redacted]";
export class Secret {
#value;
constructor(value, name) {
this.#value = value;
this.name = name;
}
expose() {
return this.#value;
}
toString() {
return REDACTED;
}
toJSON() {
return REDACTED;
}
[Symbol.for("nodejs.util.inspect.custom")]() {
return `Secret(${this.name})`;
}
}
Now console.log(secret) prints Secret(STRIPE_SECRET_KEY), JSON.stringify({ secret }) produces {"secret":"[redacted]"}, and a template literal like `key=${secret}` yields key=[redacted]. The only way to get the real value out is .expose() — a single greppable token that shows up in code review.
Wire it into one env module that parses at boot and fails loudly, then removes the raw values from the ambient environment:
// env.js — the only file allowed to touch process.env
import { z } from "zod";
import { Secret } from "./secret.js";
const schema = z.object({
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
SESSION_SECRET: z.string().min(32),
LOG_LEVEL: z.string().default("info"),
});
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
console.error("Invalid environment:", parsed.error.flatten().fieldErrors);
process.exit(1);
}
const SENSITIVE = ["DATABASE_URL", "STRIPE_SECRET_KEY", "SESSION_SECRET"];
export const env = Object.freeze({
...parsed.data,
...Object.fromEntries(
SENSITIVE.map((k) => [k, new Secret(parsed.data[k], k)]),
),
});
// Shrink the blast radius: after this point the raw strings are not
// in the ambient environment, so a subprocess or a dump can't pick them up.
for (const key of SENSITIVE) delete process.env[key];
Call sites become explicit:
import { Pool } from "pg";
import { env } from "./env.js";
export const pool = new Pool({ connectionString: env.DATABASE_URL.expose() });
Two honest caveats. First, delete process.env.X only helps if it runs before anything else reads that variable, so this module must be imported first — and you must not delete variables that libraries read from the environment themselves (AWS_* for the AWS SDK, PGPASSWORD for libpq, OTEL_* for OpenTelemetry). Delete only the keys your own code owns. Second, the Secret wrapper stops accidental printing, not a determined .expose() in the wrong place; it converts a whole class of invisible mistakes into a visible one.
A secret that redacts itself on serialization turns "we should be careful with logging" into a property of the type system rather than a team norm.
What about the loggers and error reporters?
Structured loggers can redact by path. With Pino:
import pino from "pino";
export const log = pino({
redact: {
paths: [
"req.headers.authorization",
"req.headers.cookie",
"*.password",
"*.token",
"*.secret",
],
censor: "[redacted]",
},
});
The limitation matters more than the feature: path-based redaction only inspects the properties of the logged object. If someone writes log.info(`connecting to ${url}`) with the secret already interpolated into the string, redaction sees a plain message and does nothing. That's precisely why the Secret class above overrides toString — the two mechanisms cover each other's gaps.
For error tracking, the risk isn't usually the framework grabbing your environment; it's you attaching a config object to the scope. Sentry's Node SDK doesn't ship process.env on its own, but setContext("config", config) will happily upload whatever you hand it, and a beforeSend hook is the right place to enforce that:
Sentry.init({
dsn: process.env.SENTRY_DSN,
beforeSend(event) {
delete event.contexts?.config;
if (event.request?.headers) {
delete event.request.headers.authorization;
delete event.request.headers.cookie;
}
return event;
},
});
Redaction that runs at the logging boundary is necessary but not sufficient — anything that stringifies a secret before it reaches the boundary sails straight through.
How do subprocesses and CI leak secrets?
child_process.spawn and exec pass the parent's entire environment to the child by default. Every shell script, every image-processing binary, every migration tool you shell out to receives your full secret set, and if that child prints its environment on error, so does your log. Pass an allowlist instead:
import { spawn } from "node:child_process";
spawn("./scripts/import.sh", [], {
env: {
PATH: process.env.PATH,
NODE_ENV: process.env.NODE_ENV,
IMPORT_BUCKET: process.env.IMPORT_BUCKET,
},
});
CI has the same shape with an extra trap. GitHub Actions masks registered secret values in log output, but the mask is a literal string match: base64-encode a secret, slice it, or embed it in a JSON payload you print, and the transformed value is not masked. Treat CI masking as a safety net for typos, not as a control.
Finally, make the invariant testable so it doesn't decay:
import { inspect } from "node:util";
import { env } from "../env.js";
test("env never serializes secret values", () => {
const dump = JSON.stringify(env) + inspect(env, { depth: 5 });
expect(dump).not.toMatch(/sk_[A-Za-z0-9]/);
expect(dump).not.toMatch(/postgres:\/\/[^@\s]*:[^@\s]+@/);
});
Pair it with an ESLint no-restricted-properties rule banning process.env outside env.js, and the runtime hygiene survives new contributors.
If your secret handling isn't covered by a test that fails, it's a convention, and conventions regress at the third hire.
Does any of this change which store you should pick?
Slightly, at the margins. If you want an injector that also scrubs the child process's output, 1Password's CLI is the one that masks known secret values in the stdout and stderr of the command it runs, which catches sloppy prints you didn't anticipate — though it only knows the values it injected, and it puts a vendor CLI in the boot path of every local command. Doppler's doppler run is the most frictionless injector for a mixed team and handles rotation cleanly, but it's still a plain env injection, so everything above still applies. Vault can template secrets to a tmpfs file that your app reads and re-reads, which keeps them out of the environment entirely and supports rotation without a restart — at the cost of running and operating Vault, which is a real job. As of mid-2026, no injector removes the need for the Secret type and the logging rules; they only reduce how much you're gambling on discipline.
Pick the store for rotation and access control, then assume it delivers a plain env blob and design the runtime as if it did.
FAQ
Is it safe to store secrets in environment variables?
It's acceptable for most small teams, but it's not free. Environment variables are visible to child processes, readable at /proc/<pid>/environ by same-user processes on Linux, and easy to serialize by accident. Load them once at startup, wrap them in a type that redacts on serialization, and delete the raw values from process.env afterward.
How do I stop secrets from appearing in logs in Node.js?
Use two layers. Wrap secret values in a class that overrides toString, toJSON, and Symbol.for("nodejs.util.inspect.custom") so interpolation and inspection print [redacted], and configure your structured logger's redaction paths for request headers and common field names. Path-based redaction alone misses secrets already interpolated into a message string.
Do child processes inherit environment variables in Node.js?
Yes. spawn, exec, and fork pass the parent's full process.env to the child unless you set the env option explicitly. Pass an allowlist containing only the variables the child actually needs.
Bottom line
Choosing between a cloud secret manager, Doppler, 1Password, Vault, and SOPS is a real decision, and it's the right one to make for rotation speed and access control. But it ends at your process boundary. Spend the afternoon after that decision on the runtime side: one env module that parses and fails fast, a Secret type that can't serialize, logger redaction, an allowlist for subprocess environments, and one test that fails if a raw value ever shows up in a dump. That's the part that actually determines whether your next incident is a stack trace or a secret in a log line someone screenshots into Slack.
Top comments (0)