DEV Community

Libme
Libme

Posted on

Your Secrets Manager Ends at process.env: Where Secrets Actually Leak at Runtime

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})`;
  }
}
Enter fullscreen mode Exit fullscreen mode

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];
Enter fullscreen mode Exit fullscreen mode

Call sites become explicit:

import { Pool } from "pg";
import { env } from "./env.js";

export const pool = new Pool({ connectionString: env.DATABASE_URL.expose() });
Enter fullscreen mode Exit fullscreen mode

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]",
  },
});
Enter fullscreen mode Exit fullscreen mode

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;
  },
});
Enter fullscreen mode Exit fullscreen mode

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,
  },
});
Enter fullscreen mode Exit fullscreen mode

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]+@/);
});
Enter fullscreen mode Exit fullscreen mode

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.

Related reading

Top comments (0)