Skip to content

Blog

Secrets Don’t Belong in Config

Applications should not require passwords, API keys, or tokens in their configuration files.

Configuration describes behavior. It belongs in git, code review, bug reports, and developer machines.

A secret grants authority. It needs restricted access and independent rotation.

Putting both in one file couples different lifecycles and audiences. If rotating a password requires regenerating application configuration, the interface has coupled them too tightly.

We audited all 445 NixOS modules that handle a real secret in nixpkgs at commit 141f212, classifying each by where its secret value ends up.

Where the secret value ends upModulesShare
Merged into a config file at runtime11025%
Inlined into a config in /nix/store429%
Delivered as an environment variable16136%
Left in a dedicated file opened by the app5813%
Loaded through systemd credentials5312%
Passed as a command-line argument194%
Classification uncertain2

The interesting number is 110. A quarter of the modules retrieve a secret safely, then copy it into configuration because that is the only interface the application accepts.

These modules use envsubst, replace-secret, jq, yq, sed, or custom code to assemble a restricted file at startup. The result can be secure, but every module now owns application-specific, security-sensitive glue just to combine two inputs that should have remained separate.

This is not unique to NixOS. The same workaround appears as an entrypoint script, Helm template, init container, or CI interpolation step on other platforms.

As a side note, 42 modules can inline secrets into the world-readable /nix/store. That direct security problem is tracked in nixpkgs issue #24288. The 110 runtime mergers make the broader point: even when deployment authors avoid the leak, the missing separation still creates work.

Applications should accept secret values through a dedicated runtime channel, such as:

  • a password_file or token_file setting;
  • a systemd credential;
  • a narrowly scoped environment variable;
  • or an external secret provider.

These mechanisms are not equally safe: environment variables can be inherited, arguments can appear in process listings, and files still need correct permissions. What separation does guarantee is that the deployer no longer has to manufacture a second, secret-bearing version of the configuration.

The principle is simple; implementing it across environments is not. Local development might use a system keyring, CI environment variables, and production 1Password or Vault. Without a shared abstraction, each environment needs its own naming, lookup, validation, and injection glue.

Cachix historically stored its auth token and per-cache signing keys in ~/.config/cachix/cachix.dhall, alongside cache names and other configuration. It was convenient, but the file had to be treated as a secret even though much of it was ordinary configuration.

A typical file mixed them directly:

~/.config/cachix/cachix.dhall
{ authToken = "XXX-AUTH-TOKEN"
, binaryCaches =
[ { name = "mycache"
, secretKey = "XXX-SIGNING-KEY"
}
]
}

The cache name is configuration; the auth token and signing key are secrets. You could not share the cache configuration without also sharing credentials.

devenv 2.2 separates the token through its declarative secrets integration. The project declares CACHIX_AUTH_TOKEN, devenv resolves it from the configured provider, and the value is passed to Cachix without being added to devenv’s configuration.

Cachix PR #737 brings the same boundary into the client through the Monosecret Haskell SDK. It resolves CACHIX_AUTH_TOKEN and CACHIX_SIGNING_KEY from Monosecret and can store them in the user’s chosen provider instead of cachix.dhall. Existing environment variables and config files remain higher-priority fallbacks for compatibility. The PR is still open.

That is the problem Monosecret is designed to solve: configuration declares the requirement, while each environment chooses where the value lives.

Monosecret applies that separation by making monosecret.toml a declaration of what an application needs, without storing the values:

monosecret.toml
[project]
name = "myapp"
[profiles.production]
DATABASE_URL = { description = "Postgres connection string" }
STRIPE_API_KEY = { description = "Stripe secret key" }

Providers decide where the values live. A developer can use the system keyring, CI can use environment variables, and production can use 1Password, Vault/OpenBao, or a cloud secret manager without changing the declaration.

An existing application can receive the resolved values at startup:

Terminal window
$ monosecret run -- ./myapp

Applications can also resolve them directly through the Monosecret SDKs for Rust, Python, Go, Ruby, Node.js/TypeScript, Haskell, PHP, C#, and Swift (0.2+), all sharing the same resolver so behavior stays consistent across languages.

Providers own where secret values come from. SDKs give applications an idiomatic way to consume them. Configuration remains a shareable declaration of what is required.

If you maintain an application, stop adding passwords and tokens to ordinary configuration schemas. Accept a file reference, credential, environment variable, or provider instead.

For NixOS, Monosecret issue #65 tracks how an official integration could declare and resolve secrets without per-module substitution glue.

Consistent secret handling across developer machines, CI, and production used to require infrastructure that only dedicated platform teams could build. A project of any size should be able to separate secrets from configuration without building its own secrets platform first.

Monosecret 0.12: audit logs and coding agents

A coding agent reaches for the same secrets you do, but on its own initiative and many times a session: a read looks identical whether it came from you running a deploy or an agent exploring the codebase.

Monosecret 0.12 makes that access accountable. It ships three things:

  • Audit log — every secret read and write is appended to a local, per-user JSONL log. On by default. Values are never recorded.
  • Reason-on-access — secret access can require a human-readable reason, enforced for coding agents by default.
  • monosecret audit command — filter and summarize the log, or pipe raw JSON Lines to jq.

Every secret read and write, from the CLI and the Rust SDK, is appended to a local log as JSON Lines, one event per line. Secret values are never written, only metadata: the secret name, the profile, the provider that served it (with any embedded credentials redacted), the outcome, the reason, and who was asking, including the detected coding agent.

{
"v": 1,
"ts": "2026-06-04T17:04:00.893Z",
"action": "get",
"project": "my-app",
"profile": "production",
"key": "DATABASE_URL",
"provider": "keyring://",
"outcome": "found",
"reason": "deploy web frontend",
"actor": { "user": "alice", "agent": "claude-code", "is_agent": true },
"version": "0.12.0"
}

The log lives in your per-user state directory (~/.local/state/monosecret/audit.log) and is created readable only by you. Read it with any tool, or use the new monosecret audit command for filtering and a readable summary:

Terminal window
# Last 20 entries, formatted
monosecret audit -n 20
# Only `run` events for one project
monosecret audit --project my-app --action run
# Raw JSON Lines, piped to jq
monosecret audit --json | jq 'select(.outcome == "missing")'

It is configured in your user-global config (~/.config/monosecret/config.toml), not the project’s monosecret.toml, so a repository you clone can’t quietly turn off or redirect your audit log. The log is a single file capped at 1 MiB, a size-bounded recent record rather than permanent compliance history; forward it to a central system if you need that. To turn it off entirely:

~/.config/monosecret/config.toml
[audit]
enabled = false

See Audit Logging for the full record schema and options.

When a coding agent like Claude Code reaches for a secret without a reason, the access is refused and the agent is told exactly what to do next:

$ monosecret run -- npm test
Error: Accessing secrets requires a reason. Provide one with --reason
"<why you are accessing these secrets>", the MONOSECRET_REASON environment
variable, or Secrets::with_reason() in the SDK. (Policy: require_reason in
[project] of monosecret.toml — defaults to "agents"; set it to false to
disable.)

Claude Code reads that message, states why it needs the secret, and retries:

Terminal window
monosecret run --reason "run the test suite before opening a PR" -- npm test

Both the refusal and the successful retry land in the audit log, so the reason is tied to the access. There are three ways to supply a reason:

SourceScopePrecedence
--reason flagCLIhighest
Secrets::with_reason()SDKoverrides env
MONOSECRET_REASONCLI + SDK + derivelowest
Terminal window
# CLI: the most explicit option, overrides the others
monosecret run --reason "deploying release 0.12" -- ./deploy.sh
// SDK: the programmatic equivalent of --reason
let secrets = Secrets::load(/* ... */)?.with_reason("nightly backup job");
Terminal window
# Env: lowest precedence, but honored everywhere
export MONOSECRET_REASON="nightly backup job"

MONOSECRET_REASON is resolved by Secrets::load / load_from, which means monosecret_derive-generated code and other library callers satisfy the policy and supply an audit reason without any code changes.

Whichever path you use, blank or whitespace-only reasons are ignored, so they can’t quietly satisfy the policy. Under the hood this is backed by a new Provider::set_reason trait method (a no-op by default), so existing providers keep working unchanged.

The new require_reason policy in the [project] table controls when a reason is mandatory:

[project]
name = "my-app"
require_reason = "agents" # require it from agents (default), or true / false
  • "agents" (the default): require a reason only when a coding agent is detected.
  • true: require it from every caller.
  • false: never require it.

Because the policy lives in monosecret.toml and is enforced by Monosecret, it applies to everyone and every CI runner, and is inherited through extends. Coding agents are spotted by the detect-coding-agent crate (Claude Code, Cursor, Codex, Gemini CLI, Copilot, and more); set MONOSECRET_AGENT for a harness it doesn’t recognize.

Terminal window
cargo install monosecret

Remember the new default: agents must pass a reason: set require_reason = false to opt out.

Questions or feedback? Join us on Discord.