I still use .env files in local development.
They are simple, they work with almost every framework, and they are easy to understand:
OPENAI_API_KEY=sk-...
DATABASE_URL=postgres://...
But as I started using coding agents more often, plaintext secrets in project directories started to bother me.
Giving an agent access to a repository is not exactly the same as giving another person access to your working directory, but it is close enough to make me more careful. If the real API key is sitting in .env, it is easier to accidentally include it in a prompt, log output, screenshot, terminal paste, or debugging transcript.
There are already serious tools for this problem: 1Password CLI, Infisical, direnv, dotenv-based workflows, and tools like chamber. For many teams, one of those is probably the right answer.
I wanted something smaller for my own local development:
- keep only repository-safe references in
.env - store real credentials in the OS credential store
- resolve them only when launching a command
- optionally avoid passing the real provider key to the child process when an API client supports a custom endpoint
So I built EnvVault, a small local-first secret launcher.
The basic idea
Instead of putting real secrets in .env, EnvVault lets you write references:
OPENAI_API_KEY=envvault://openai/dev
DATABASE_URL=envvault://database/dev
The real values are stored outside the repository, in the OS credential store. On macOS, EnvVault uses the default Keychain. At launch time, envvault exec reads the .env file, resolves envvault://... references, and starts the child process with the resolved environment.
The default flow looks like this:
OS credential store
envvault/credential/openai/dev/value = sk-...
^
|
envvault exec
|
v
.env
OPENAI_API_KEY=envvault://openai/dev
|
v
child process
OPENAI_API_KEY=sk-...
The important part is that the repository-visible .env file does not contain the raw secret.
This does not make the launched process magically safe. In the default mode, the child process still receives the real credential as an environment variable. EnvVault is not a sandbox. The goal is narrower: reduce the places where raw secrets casually sit in project files.
Quick example
Install it with Homebrew:
brew install trknhr/tap/envvault
Add a credential:
printf 'YOUR_API_KEY\n' | envvault credential add openai/dev --value-stdin
Then put only the reference in .env:
OPENAI_API_KEY=envvault://openai/dev
Launch your app through EnvVault:
envvault exec --env-file .env -- npm run dev
Everything after -- is the child command.
You can also skip the .env file and pass references inline:
envvault exec \
--env OPENAI_API_KEY=envvault://openai/dev \
-- npm run dev
There is also a small localhost admin UI:
envvault admin start
The UI can add credentials, list credential names, and create optional proxy configurations. It does not display stored credential values. You can add a value, but EnvVault intentionally avoids becoming a "show me all my secrets in the browser" interface.
Why direct credential resolution is the default
At first, I was more interested in proxy mode.
In proxy mode, .env can contain references like this:
OPENAI_BASE_URL=envvault://openai-proxy/dev/base-url
OPENAI_API_KEY=envvault://openai-proxy/dev/token
At runtime, EnvVault starts a localhost proxy and gives the child process a local URL and a local bearer token. The real provider API key stays in the OS credential store. The proxy checks the local token, method, and path allowlist before forwarding the request upstream with the real provider key.
That is useful when it fits.
But it only fits when the SDK or application lets you configure a custom base URL and bearer token. Many libraries just want OPENAI_API_KEY, DATABASE_URL, or another provider-specific environment variable. Some credentials are not HTTP API tokens at all.
So the default path is direct credential resolution. It is less ambitious, but it works with almost everything that already expects environment variables.
Proxy mode is still there for APIs where a custom endpoint makes sense. It is just not the main compatibility story.
What EnvVault tries to protect
EnvVault is meant to reduce accidental exposure in local development.
It can help with:
- not committing raw secrets to a repository
- keeping
.envfiles repository-safe - resolving secrets only at process launch
- failing closed when a reference cannot be resolved
- using strict
envvault://parsing instead of accepting many ambiguous forms - using proxy mode to avoid giving the child process the real provider key, when the SDK supports it
It does not protect against everything.
It cannot stop a child process from reading its own environment variables. It cannot hide secrets from a malicious process running as the same OS user with the same credential-store permissions. It does not redact your application logs, shell history, prompts, stdout, stderr, HTTP bodies, or screenshots.
That tradeoff is intentional. EnvVault is not trying to replace a production secret manager or a sandbox. It is a small local tool for keeping raw secrets out of the repository surface area.
A note for coding agents
EnvVault also includes an agent skill:
npx skills add trknhr/envvault --skill envvault
The idea is simple: if an agent needs to run commands that depend on credentials, it should know how to use envvault exec and envvault:// references instead of asking to inspect plaintext .env files.
That is the workflow I wanted for myself. I can keep a project-local .env file with stable variable names, let my tools launch normally, and avoid leaving raw API keys in the repo directory by default.
Closing
If all you need is "do not put secrets in .env", there are many existing options. EnvVault is not meant to be the universal answer.
For my own local projects, though, I wanted a small pattern:
OPENAI_API_KEY=envvault://openai/dev
The real value lives in the OS credential store. The repository gets a reference. The app receives the resolved value only when I launch it through envvault exec.
That is the whole idea. Small, local-first, and honest about its limits.
Top comments (0)