I checked Claude Code 2.1.257 after authenticating to several remote MCP
servers. The file ~/.claude/.credentials.json had mode 0600, as it should,
but it also contained a top-level mcpOAuth object with the access tokens.
Here is the shape of one Cloudflare entry, with every credential value
redacted:
The browser flow makes the secret easy to miss. Run claude mcp login, approve
access in the browser, and return to a connected MCP server. Nobody manually
created a token, copied it from a dashboard, or pasted it into a configuration
file.
But Claude Code still received a credential. It must persist that credential
if the connection is to survive a restart.
OAuth is valuable here. Claude Code can discover the authorization server,
request specific scopes, complete the authorization-code exchange, refresh an
access token, and revoke the grant. Anthropic’s MCP documentation
also lets users pin the scopes Claude Code requests.
What OAuth does not specify is a secure local vault.
API tokens can also be scoped, limited to particular resources, assigned an
expiry, rotated, and revoked independently. OAuth standardizes delegation and
renewal, while avoiding the copy-and-paste ceremony. Those are substantial
benefits, but they do not turn the resulting bearer token into something that
is safe to leave in plaintext.
Property
OAuth credential
Scoped API token
Must be stored by the client
Yes
Yes
Can have limited permissions
Yes
Yes
Can expire
Yes
Yes
Can be revoked independently
Usually
Yes
Can be replayed if stolen
Yes
Yes
Standard interactive delegation
Yes
Provider-specific
Standard automatic renewal
Often
Usually external
OAuth solves how Claude Code obtains and renews a delegated credential.
Secret storage solves what happens to that credential between uses. They are
separate concerns.
Claude Code should not decide that every Linux user’s MCP tokens belong in the
same plaintext file. The persistence layer should be replaceable:
Claude Code OAuth client
│
▼
credential-store interface
│
▼
Monosecret TypeScript SDK
│
▼
user-selected Monosecret provider
The right integration point is the Monosecret Node.js / TypeScript SDK. It embeds the Rust resolver, so the TypeScript side does not
need bespoke code for each backend. Claude Code could serialize one MCP OAuth
credential per server and ask Monosecret to load, save, or delete it using the
provider the user or organization selected.
Monosecret 0.20 has 33 provider integrations. They cover local
keyrings, password managers, encrypted files, cloud secret managers, and
deployment destinations. Providers declare their capabilities, so a credential
store can require readable and writable storage while still using the same
interface everywhere.
The current TypeScript SDK exposes Monosecret’s provider-independent resolver.
We would add the small get/set/delete credential-store surface Claude
Code needs rather than reimplement 33 integrations in its codebase.
For example, set the system keyring as the default
provider in the local user configuration:
A team might instead require OpenBao or a cloud secret
manager. A headless workstation might use an age-encrypted store. The OAuth flow would stay exactly the same; only
persistence would change.
What Codex does: Codex makes MCP OAuth storage configurable. Its
configuration reference
documents auto, file, and keyring backends. Setting
mcp_oauth_credentials_store = "keyring" selects the system keyring. This is
not a general secret-provider interface, but it avoids making a plaintext
credential file the only option on Linux.
Coming in Monosecret 0.21: We are working on versioned resolver and provider IPC for
zero-dependency integrations. Applications will be able to use Monosecret
providers over a local protocol without embedding an SDK or provider code.
We are working to make open source tools retrieve credentials from a
user-selected secret store instead of copying them into another plaintext
file. Monosecret now provides Git and
Docker credential helpers, and we have proposed a
generic, operation-scoped secret resolver interface for Nix.
Because these projects are open source, we can inspect their credential
boundaries and contribute safer ones upstream.
That is one of the practical security benefits of open source: when a secret
crosses the wrong boundary, users do not have to wait for the vendor to decide
that the boundary matters.
We have released dotenv-ng 1.0, a
modern Rust implementation for loading and rendering .env files. It began as
a fork of dotenvy after its parser
changed a secret while reading it.
The file was intact. Reading it through the dotenv provider returned a
different value because dotenvy treated the dollar-prefixed fragments as
variable substitutions. The failure appeared later as an authentication error,
not a parse error.
An upstream request to make substitution configurable had been open since 2024. A pull request arrived in 2026 but
targeted an unreleased API. A migration tool cannot require users to recognize
and escape parser syntax inside their secrets.
The original Rust dotenv crate stopped releasing in 2020 and was eventually
marked unmaintained by RustSec, which listed
dotenvy as an alternative.
Dotenvy’s description still calls it “a well-maintained fork.” Its latest
published version, 0.15.7, was released on March 22, 2023. A Rust forum discussion
noted the two-year release gap in 2025. By the time the bcrypt bug blocked
Monosecret, it was more than three years.
There is an uncomfortable irony in a maintained fork repeating its upstream’s
release problem. Its maintainers do not owe us a release, but Monosecret needed
breaking fixes on a schedule we control.
We first considered a small patch. Auditing the parser uncovered more problems
around JSON, Windows paths, Unicode names, precedence, and partial environment
mutation.
dotenv-ng therefore starts from dotenvy 0.15.7 but deliberately breaks
compatibility where correctness requires it. Version 1.0 adds:
a source-aware parser with structured errors;
literal dollar signs by default, with substitution available only when a
caller explicitly enables it;
a broader key grammar that supports dashes, leading digits, leading dots,
and Unicode;
a renderer that adds only the quoting and escaping needed to parse a value
back unchanged;
validation before process-environment mutation; and
an explicit unsafe boundary around that mutation.
Property tests exercise arbitrary Unicode and syntax-heavy values, check that
quoting is used only when necessary, and round-trip complete documents. The
parser and renderer, the core of the rewrite, both have 100% line coverage.
Secret storage changes as a project grows. Values move from local files to
password managers, from one naming convention to another, and sometimes
between providers with completely different data models. The application still
expects the same API_KEY or DATABASE_URL at the end.
Monosecret 0.19
treats those changes as a normal workflow instead of a one-off migration
script.
This release includes:
Provider-specific storage layouts: give each provider its own
address, transform stored values, import existing files, and preview exact
write references.
Config belongs in secrets: resolve
profile-specific config alongside stored secrets, generate ephemeral values,
and securely prompt during monosecret run.
Passbolt provider: read and write secrets in a
self-hosted Passbolt server, with credentials supplied by another provider
when needed.
API_KEY is the name used by your application. In 1Password, the same value
might be the token field of an item named old-api-item.
A ref gives Monosecret this store address. item
names the entry. Coordinates such as field, section, and vault locate a
value inside structured stores. The providers list still decides which
stores to try.
Before 0.19, every provider in a secret’s route received the same ref, even
though stores such as 1Password and dotenv organize secrets differently. Now
each provider alias can template its usual layout, while refs.<alias> handles
exceptions:
monosecret.toml
[providers]
legacy = "onepassword://Legacy"
production = {
uri = "onepassword://Production",
ref = { item = "{project}-{profile}", field = "{key}" }
}
local = { uri = "dotenv://.env", ref = { item = "{key}" } }
production reads the API_KEY field from a <project>-production 1Password
item. The local fallback reads the dotenv key API_KEY. If legacy is
selected explicitly, refs.legacy reads the token field from old-api-item.
For each provider, refs.<alias> takes precedence over the alias’s ref
template, which takes precedence over the provider convention. Templates
accept {project}, {profile}, and {key} in every address field. Existing
route-wide ref declarations remain supported.
Because scoped references also apply to imports, that exception can describe a
migration source without joining the normal fallback route:
This reads from refs.legacy and writes through the production template. It
also works between distinct entries in one physical store. Monosecret rejects
the import if both addresses resolve to the same entry.
extract = { format = "json", pointer = "/database/password" }
}
JSON strings become their unquoted contents. Numbers, booleans, objects, and
arrays keep their JSON representation. Extracted declarations are read-only.
set, delete, prompting, generation, and import cannot overwrite the source
document.
encoding defines the textual representation in provider storage:
Supported encodings are standard Base64, URL-safe Base64, and hexadecimal.
Writes encode the logical value. Reads decode the stored value. Decoded UTF-8
can be returned directly. Set as_path = true to materialize arbitrary bytes
in a file.
File stores one plaintext UTF-8 file per secret beneath
a required root. Convention paths use {project}/{profile}/{key}. ref.item
selects an existing relative path, including a file mounted at runtime.
Writes use atomic replacement and create private Unix files and directories.
The provider rejects traversal and nested symlinks. It does not encrypt its
contents.
The file provider is also a migration adapter for directories that already
contain one file per secret. A provider ref template maps the source layout,
while the destination alias independently maps the same declarations into its
native store:
monosecret.toml
[providers]
legacy_files = {
uri = "file:./old-secrets",
ref = { item = "{profile}/{key}" }
}
production = {
uri = "onepassword://Production",
ref = { item = "{project}-{profile}", field = "{key}" }
For API_KEY, the source is old-secrets/production/API_KEY. The destination
is the API_KEY field in the <project>-production 1Password item. The source
files do not need to follow the Monosecret convention. With
--delete-source, 0.19 preflights every mapped source and destination, verifies
every copied value, and only then removes the plaintext source files.
Preflight, write, or verification failures leave every source untouched. A
destination with a different existing value keeps its corresponding source.
Enter value for API_KEY (profile: production): ********
SOPS reports the canonical encrypted file and exact sops set selector. Other
providers report their native item or path. A missing profile or unexpected
template is visible before Monosecret receives the new value.
Null always reports a missing value and stores nothing.
This lets manifest defaults provide non-sensitive values without adding a
storage backend. One resolution can now return profile-specific configuration
and provider-backed secrets together. This follows the separation described in
Secrets Don’t Belong in Config.
APP_MODE resolves to local, staging, or production based on the selected
profile. Each override inherits the description and null route from
[profiles.default]. Only the value is repeated.
The result is one declaration model for values the application needs, whether
they come from a secret store or directly from the manifest. Config can travel
through the same profile, scope, SDK, and run workflow without pretending it
needs encrypted persistence.
The null provider can also generate a fresh value for each resolution. Use it
for session tokens, test credentials, and other values that should exist only
for one process invocation. Persistent credentials should continue to use a
writable provider.
Set prompt = true on a declaration to let monosecret run securely request
its value when the configured providers do not have one:
monosecret.toml
[profiles.default]
DEPLOY_PASSWORD = {
description = "One-time deployment password",
prompt = true,
providers = ["null"]
}
Terminal window
$ monosecret run -- ./deploy
? Enter value for DEPLOY_PASSWORD (profile: default):
A writable provider saves the answer, turning the prompt into first-use
provisioning. The null provider keeps it ephemeral and injects it only into
that invocation. The hidden prompt reads from the controlling terminal, so the
child’s stdin remains available for pipes and redirects. If no controlling
terminal exists, run fails before starting the child. Declarations without
prompt = true retain the existing fail-on-missing behavior.
Passbolt is the third new provider in 0.19. Monosecret now has 27 providers.
Passbolt reads and writes resources in a
self-hosted Passbolt server through go-passbolt-cli. Convention values use
the resource monosecret/{project}/{profile}/{key} and its password field.
References can select existing resources by UUID or exact name and address the
password, username, uri, or description field.
monosecret.toml
[providers]
bootstrap = "keyring://"
[providers.passbolt_team]
uri = "passbolt://?server=https://pass.example.com&folder=a9230ec4-5507-4870-b8b5-b3f500587e4c"
The OpenPGP private key and passphrase can come from another Monosecret
provider. Environment fallbacks and the Passbolt CLI configuration are also
supported. Folder-scoped providers support declaration discovery with
init --from.
Remote secret reads pay for authentication, process startup, and network
round-trips before the application can start. Monosecret 0.19 reduces that work
both across invocations and within one resolution.
A single authoritative provider can now define uri, credentials, and
cache on the same alias:
monosecret.toml
[providers]
local = "keyring://monosecret/cache/{project}/{profile}/{key}"
azure = {
uri = "akv://team-vault",
credentials = { client_secret = "keyring" },
cache = { provider = "local", max_age = "8h" }
}
[profiles.development.defaults]
providers = ["azure"]
The cached fallback form introduced in 0.17 remains available when several
authoritative providers can answer.
Cache entries now include their absolute expiration time and originating
max_age. Monosecret removes an expired entry whenever it encounters one, and
changing max_age invalidates entries written under the previous policy.
Fallback resolution also reuses provider instances and handles independent
primary misses concurrently. Azure Key Vault reuses its client and serializes
initial challenge-based authentication, avoiding repeated Azure CLI processes
within one resolution.
1Password field references now resolve together through one op inject call,
instead of starting op read separately for every field. This reduces CLI
startup and repeated unlock overhead when one profile loads several fields. If
the batch contains a missing reference, Monosecret falls back to bounded
concurrent reads so it can preserve per-secret missing-value behavior without
serializing the whole profile.
In the cold-cache benchmark from
the implementation PR, a
representative profile with 25 field references resolved in 11.890 seconds,
down from 96.294 seconds. The batch used 3 op processes instead of 27, making
that run 8.10 times faster.
cargo cinstall -p monosecret_ffi now installs the library, C header, and a
monosecret_ffi.pc file containing the complete link metadata. Go builds can
use the pkgconfig tag, Ruby native extensions accept --enable-pkg-config,
and Haskell builds use the use-pkg-config Cabal flag. The same metadata
supports installed static or shared libraries.
Haskell now declares its required macOS system frameworks. The Rust SDK’s
ProviderAlias type also exposes leaf, credentials, and credentials_mut
helpers for configuration tooling.
Existing route-wide ref declarations, inheriting profiles, and cached
fallback aliases remain compatible. All new configuration fields and providers
are opt-in.
These items are not part of 0.19. They are open work for future releases:
Native Windows ARM64 CLI archive (target: 0.19.1): add
monosecret-aarch64-pc-windows-msvc.zip and its checksum to GitHub Releases
so the CLI can run natively on Windows ARM64. The static installer will
continue to select the x64 build on Windows ARM devices until it supports the
native archive, and standalone updates depend on
axoupdater supporting Windows ARM64.
.env is one of software’s most successful accidents.
It starts as a shortcut for three export commands. Then it becomes the
project’s configuration schema, secret store, environment model, onboarding
guide, CI interface, and deployment format.
Environment variables do one job well: deliver strings to a process. .env
turned that delivery mechanism into a source of truth.
A convenience became architecture. That is where .env went wrong.
Environment variables solve a small problem: getting values into a running
process. The application can read DATABASE_URL without knowing whether a
developer, CI system, or secrets manager supplied it.
A .env file makes those values easy to save and reload. That is useful. But
teams also use the file to describe what the application needs. KEY=value
cannot say whether a value is required, secret, safe to commit, available only
in production, or restricted to one service.
Those requirements outlive any process and any developer laptop. They belong
in a durable project declaration. .env stores values for delivery; it cannot
define the application’s secret model.
The file raises more questions than it answers. Does an empty value mean
required or optional? Is REDIS_URL a development default? Is
STRIPE_API_KEY production-only? Is DEBUG a boolean?
Teams put the missing information elsewhere: validation code, a README,
.env.example, or a teammate’s memory. These sources drift.
The file also makes DEBUG and STRIPE_API_KEY look equivalent. One is an
ordinary setting that belongs in Git. The other grants authority and needs
access control and rotation. Mixing them makes the whole file sensitive.
Without an explicit declaration, missing values fail late: the application
discovers them only when code tries to use them.
The filenames become an environment model. Suffixes define scope, load order
defines inheritance, and copying a file becomes deployment.
This reverses the
Twelve-Factor App’s guidance. Its point was that
environment variables should be independent controls because named
environments become brittle as deployments multiply. .env.production
recreates that grouping in a filename.
Now every new value must be added to .env.example, documented in a README,
validated in code, and copied into the right real files. Miss one and the
environments drift.
python-dotenv expands ${NAME} but not $NAME. Node dotenv delegates variable expansion to another
tool. Docker Compose supports its own
shell-style operators.
Vite even supports references in reverse order, then
warns
that the same expression will not work in a shell or Docker Compose.
Parsers also disagree about precedence.
Node dotenv normally lets the first
file win. Docker Compose
lets the last env_file win, then lets the environment section override
that. Vite
gives an existing process variable priority over its files.
Docker Compose gives two similar names different behavior. env_file: supplies
variables to a container but does not use them to interpolate compose.yaml.
docker compose --env-file does affect interpolation. In an issue closed as working as designed, a maintainer described
the option’s name as unfortunately chosen.
Precedence also depends on timing. Node dotenv’s ES module guidance
needs special handling when imported modules read the environment during
initialization. Vite warns that Bun’s automatic .env loading can interfere
with Vite’s own loading order. VITE_* values are
replaced at build time and become part of the
client bundle.
The same line can become a runtime secret, a build-time constant, or a public
browser value. The loader decides based on timing and context.
At that point .env behaves like a small program, with control flow spread
across filenames, flags, working directories, parent processes, and library
versions.
The dotenv project says not to commit .env.
.gitignore prevents one accident. It does not add encryption, access control,
auditing, or revocation.
The file can still end up in editor backups, chat messages, archives, support
bundles, container build contexts, and old laptops. A devenv integration was
reported to copy .env contents into the Nix
store, where paths are not
confidential. When a developer leaves, there is no file access to revoke.
Each credential they received is a separate copy.
Even a secret stored in 1Password, Vault, a cloud secret manager, or a system
keyring must be copied into plaintext before a dotenv-based application can use
it. The local copy has fewer controls than the original.
Environment-variable delivery has limits too. Docker mounts managed secrets as
files because environment variables can
leak between containers.
A process also gets one global map, so a frontend build, worker, migration, and
web service often receive the same secrets even when each needs only a few.
Dotenv has no way to express that scope.
The useful part of .env is the short path from “this application needs a
value” to “the application can run.”
Keep it as an adapter for tools that expect KEY=value, or use it for ordinary
local settings. Do not make it define the project’s requirements, store durable
copies of secrets, encode environments in filenames, or decide which services
receive which values.
A durable design separates three jobs:
a committed declaration says which secrets the application needs;
protected storage controls who can read their values;
explicit delivery gives each process only the values it needs.
Each piece can then change independently. A team can change storage without
rewriting the application, validate requirements before startup, and limit each
component to its own secrets.
This file records requirements, defaults, and descriptions without containing
secret values. Providers choose where values live, and
profiles describe real differences in requirements.
Existing programs can adopt Monosecret without code changes:
Terminal window
$monosecretrun--./server
This command injects resolved secrets into the child process environment. It is
useful during migration, while the preferred integration is a
Monosecret SDK.
With an SDK, the application resolves its declaration directly. This removes
the environment-variable handoff used by monosecret run. Values stored in a
keyring, password manager, or Vault never enter the global process environment.
Applications that require a file can receive a
temporary file instead. Scopes (0.2+) let each component resolve only the secrets it
declares.
Migration can be gradual. Monosecret initializes a declaration from an existing
file:
Terminal window
$monosecretinit--fromdotenv:.env
This copies names without copying values. The current file can remain a
provider during the transition:
Terminal window
$monosecretcheck--providerdotenv:.env
$monosecretrun--providerdotenv:.env--./server
Values can then move to a system keyring, password manager, Vault, or another
provider without changing the names the application reads.
.env can remain for ordinary local settings. Existing applications can keep
environment-variable delivery while they migrate. Applications using an SDK or
file-based delivery can remove secrets from their process environments.
Monosecret aims to eliminate environment variables for secrets altogether.
Once you have encrypted secrets.yaml, how does your Python service consume
it? What about your Go worker or Node.js app?
You still need to decrypt the file, inject its values, select the right file for
each environment, validate required keys, and repeat that integration for every
language.
And if you release the project as open source, that choice does not stay yours.
With SOPS baked into the setup, everyone who runs or contributes to the project
must adopt SOPS and its key management, whatever secrets tooling they already
use.
Encrypted files also make the key workflow a project-wide requirement. Adding a
teammate means adding their key to .sops.yaml and re-encrypting every file;
removing one means rekeying and rotating the affected secrets, since their key
already saw the plaintext. Monosecret leaves identity and access to the
provider: onboarding to Vault or a cloud secrets manager is granting a role,
and offboarding is revoking it.
SOPS may be enough today. As your team grows more sensitive to how secrets are
handled, you may want Vault’s access policies and centralized audit trail. If
applications know about SOPS, each one needs migrating. If they know only
Monosecret, you change the provider configuration; SDK
calls and secret names stay the same.
SOPS protects a file. Monosecret gives applications a provider-independent
interface. The selected provider remains responsible for storage, encryption,
identity, access control, and availability.
I wrote a fuller Monosecret comparison showing exactly where
Monosecret ends, where providers begin, and which responsibilities belong to
each layer.
Because applications talk to an interface instead of a file, the interface can
grow without touching them. Three open proposals point where it is heading:
Project security requirements
would let a project declare the guarantees a provider must meet, such as
encryption at rest or an audit trail, and reject providers that fall short.
Lease-aware refresh would
let running applications follow key rotation and short-lived credentials
instead of restarting for a new value.
The SOPS provider (0.2+) brings SOPS itself behind the
same SDK interface, making your encrypted files one more place secrets can
come from.
With that provider, perhaps “But I use SOPS” just needs two more words:
But I use SOPS with Monosecret.
If encrypted files fit your workflow, keep using SOPS. Just recognize the
boundary: encryption at rest is not an application secrets interface.