All posts
7 min readDavid Nussio

envsec vs dotenv vs 1Password CLI vs direnv: which one, when

Your app reads a database password and an API key from environment variables. The open questions are where those values live when nothing is running, and how they reach the process when something is. dotenv, direnv, 1Password CLI and envsec all come up when you search for an answer, and they answer different parts of it.

I wrote envsec, so read this with that in mind. I've tried to be fair: for several of the cases below envsec is the wrong tool, and I say so. The comparison page has the feature grid; this is the long version, with the trade-offs.

Two questions, four tools

Every secrets setup answers two questions: where is the value stored, and how does it get into the process that needs it? The four tools split like this:

That's why "dotenv or envsec" is partly a false choice, and why direnv pairs well with any of the others.

dotenv: the default, and fine for configuration

dotenv is a zero-dependency Node.js module that loads a .env file into process.env. Many frameworks read .env files on their own, and loaders exist for most languages, so the format is everywhere.

dotenv is the better choice for non-secret configuration (ports, log levels, local URLs, feature flags), for a committed .env.example that documents what a project needs, and in containers and CI, where something else already injects the real values. The trouble starts when the same file also holds the database password. Any process running as you can read it: editor extensions, backup tools, a coding agent indexing your project.

direnv: per-directory environments

direnv is a shell extension. Before each prompt it looks for an .envrc in the current and parent directories, runs it in a bash subprocess, and applies the resulting changes to your shell. Leave the directory and the changes are undone. It has hooks for bash, zsh, fish and several other shells.

One thing to know before you load secrets with it: the variables end up in your interactive shell, so every command you start from that shell inherits them. direnv also keeps its own bookkeeping in a DIRENV_DIFF variable. On my machine (direnv 2.37.1) that variable contains the values it set, compressed but not encrypted.

direnv is the better choice for switching per-project settings that aren't secret, and as glue that calls a real secret store. More on that below.

1Password CLI: secrets for a team

1Password CLI (op) is the command-line client for a 1Password account. Its op run command reads environment variables whose values are secret references such as op://development/GitHub/credentials/personal_token, from your environment or an --env-file, and starts your command with the real values for the lifetime of that process.

If more than a couple of people share production credentials, if you need to revoke someone's access when they leave, or if an auditor asks who can read what, 1Password CLI is the better choice. If your company already pays for 1Password, start there.

envsec: the OS keychain, from the command line

envsec stores each secret in the operating system's credential store: the macOS Keychain, the Secret Service on Linux (through secret-tool), or the Windows Credential Manager. Metadata (key names, timestamps, optional expiry dates) goes into a SQLite file, ~/.envsec/store.sqlite by default. Values never go into SQLite. Secrets are grouped by context, such as myapp.dev or stripe-api.prod.

Terminal
# Import an existing .env once
$envsec -c myapp.dev load -i .env
# Run the app with every secret of the context injected
$envsec -c myapp.dev run --inject 'npm run dev'
# Or pass one secret to one command
$envsec -c myapp.dev run 'psql {db.url}'

With run, a {db.url} placeholder becomes a reference to an environment variable that only the child process sees, so the value isn't pasted into the command line. There is also envsec shell for a subshell with the secrets loaded, envsec env for eval, and envsec env-file for tools that insist on a .env file (envsec records where it wrote them, and envsec audit lists them along with expired secrets).

What envsec doesn't do: sync between machines, permissions, an access log, or rotation. It tracks expiry dates you set with add --expires and reports them in audit, but it doesn't rotate anything. It also doesn't protect you from software running as your user: on my Mac, reading a secret that envsec stored doesn't show a prompt. The threat model post goes into the details.

At a glance

envsecdotenvdirenv1Password CLI
Stores secrets inOS credential storePlaintext .envNothing of its own1Password vault
Encrypted at restYes, by the OSNo (dotenvx: yes)Not applicableYes
Gets values intoA command or your shellprocess.envYour shellA command (op run)
Team sharingOne-off GPG fileOut of bandOut of bandShared vaults
Access controlYour OS loginFile permissionsFile permissionsPer-vault permissions
Account neededNoNoNoYes
CostFree (MIT)Free (BSD-2)Free (MIT)Paid subscription
OfflineYesYesYesCheck their docs
CINot designed for itPlatform env varsNot its jobService accounts
Third-party columns reflect the official docs linked in this post. Check them before you decide.

They combine better than they compete

direnv plus envsec. The lightest version uses direnv only to pick the envsec context for a directory. No secret enters your shell; envsec reads the ENVSEC_CONTEXT variable when you don't pass -c, so envsec run --inject inside that directory uses the right context:

bash
# .envrc: choose the context for this directory, export no secrets
export ENVSEC_CONTEXT=myapp.dev

If you want the secrets in your shell as plain variables, let direnv evaluate envsec env. direnv runs .envrc in bash, so the default bash syntax is the right one even if your interactive shell is fish. I tested this with direnv 2.37.1:

bash
# .envrc: export every secret of the context into your shell
eval "$(envsec -c myapp.dev env)"

The trade-off is the one above: the values now live in your shell and in DIRENV_DIFF, and direnv calls envsec again each time it reloads the directory. I prefer the first version.

dotenv plus envsec. Keep non-secret settings in .env and the secrets in envsec. Because dotenv doesn't override variables that are already set, the injected secrets win, and the file never needs to contain them:

Terminal
# .env holds PORT, LOG_LEVEL, API_BASE_URL. No secrets.
# envsec injects the secrets; dotenv won't override them.
$envsec -c myapp.dev run --inject 'npm run dev'

1Password plus envsec. Use 1Password for what the team shares and for CI, and envsec for what is only yours: personal tokens, side projects, the things you would otherwise keep in a .env in your home directory.

Which one, when

If you're starting from a folder of .env files, From .env to the keychain in five minutes walks through the move. The comparison page has the feature-by-feature grid.