Stop Sourcing .env: Load It With direnv Instead
Is `source .env` safe? Only for a file you wrote. For a cloned repo it runs whatever the file says. Here is how I load .env with direnv instead.
In this post
Is source .env safe? It is safe for a file you wrote
yourself and unsafe for any .env you did not, because
sourcing runs the file as shell code instead of reading it as data. I
used to load .env files with a small shell function, spent
a few rounds of security review trying to make that function safe, and
then deleted it and let direnv do the job.
What source .env runs
The shell’s . and source builtins read
and execute commands from a file in your current shell. A
.env file looks like a list of KEY=value
lines, but the shell runs every line as a command, the same as any other
script.
I checked this on my own machine before writing it down. I made an
empty directory, put two lines in .env, and sourced it:
FOO=1
BAR=$(touch pwned)After . ./.env, there was a file called
pwned in the directory. The command substitution ran. Swap
touch pwned for anything else you can type in a terminal
and it runs with your permissions, in your shell, with your SSH keys and
cloud credentials in the environment.
You will not hit this with your own .env. You hit it
with the one in a repo you just cloned or a branch from a contractor.
source .env after a git clone carries the same
risk as curl | sh.
The old one-liner was also broken
My first version of that function, envup, loaded
.env like this:
export $(sed '/^ *#/ d' .env)It never executes the file, yet it has its own problem. The result of
$(...) is word-split and then glob-expanded, so a value
with a space in it becomes two arguments and a value with a
* in it turns into a list of filenames. This is a
correctness bug before it is a security bug. The first fix in my dotfiles repo was to
make envup parse the file line by line instead.
Parsing fixed both problems. A line like
D=$(touch pwned) was now just a string with a dollar sign
in it, and I added a test that fails if that file ever appears. I
thought I was done.
Parsing is not enough
I ran a security review pass in Claude Code over the function, and it found what I had missed. Not running the file does not help if the values you export are themselves things the shell or your tools will run later. The ones it flagged first:
PROMPT_COMMAND. In bash, its value is used as a command to execute before printing the primary prompt. A.envline that sets it runs at your next prompt, and every prompt after.LD_PRELOAD. On Linux, it lists extra shared objects to be loaded before all others, so every dynamically linked program you start loads the library the.envnamed.PATH. If it points at a directory of the repo’s choosing,gitorsshmay be whichever one that directory holds.GIT_SSH_COMMAND. Git uses it instead ofsshwhen it connects to a remote, and it is interpreted by the shell.
None of these are exotic. They are normal environment variables that
a .env has no business setting and that a parser has no way
to tell apart from DATABASE_URL.
So I added a denylist: refuse those names, warn, and keep going. The
test grew a case proving PROMPT_COMMAND and
LD_PRELOAD did not get through.
That was the first round. The next review found that zsh keeps the
lowercase array path and the uppercase PATH
tied together, so a .env line reading
path=/evil set PATH while walking straight
past my list of uppercase names. I restricted names to
UPPER_CASE and widened the list again:
NODE_OPTIONS, PYTHONPATH,
JAVA_TOOL_OPTIONS, LESSOPEN, anything ending
in ASKPASS, EDITOR, PAGER, and
more.
Each round found more names that execute code. My commit message for
the deletion says it plainly: “a denylist never closes.” The function
already carried a comment saying it was not exhaustive and should become
an allowlist if I ever ran it on untrusted repos as a habit. Loading
.env in repos I just cloned is that habit. I deleted the
function the same day, in favor of direnv.
What I use now: direnv
direnv hooks into your shell and loads or unloads an environment as you move between directories. When I switched, it was already installed and hooked in my shell. My dotfiles list it in the Brewfile and the Linux package list, and the shell config hooks it only when it is installed. Setting up a repo is two commands:
echo dotenv > .envrc
direnv allowdotenv is a function in direnv’s standard library that
loads a
.env file into the current environment. The
.envrc is the file direnv itself reads, and it runs only
after you approve it. Here is what that buys you:
Nothing loads until you allow it. direnv’s own docs
call the allow step the
security mechanism to avoid loading new files automatically, and
give the reason: any git repo you pull, or tar archive you unpack, would
otherwise be able to wipe your hard drive. Clone a repo, cd
into it, and direnv tells you the .envrc is blocked.
An edited .envrc is blocked again. If
git pull brings in a changed .envrc, direnv
stops until you run direnv allow once more.
It reloads on change and unloads when you leave.
dotenv watches
the file, so edits to .env show up at your next prompt.
When you cd out of the project, the variables go away. No stale
STRIPE_KEY follows you into an unrelated repo.
I checked these behaviors on direnv 2.38.2, and ran the
.env case again while writing this: I allowed a directory
with A=1 in its .env, changed it to
A=2, and direnv loaded 2 with no new
prompt.
The denylist tried to decide which variable names were dangerous, and I never finished the list. direnv asks once per directory whether I trust it, and loads nothing until I say yes.
The gap you need to know about
There is one hole, and I put it in the README of my dotfiles so I
would not forget it. direnv re-approves changes to the
.envrc. It does not re-approve changes to the
.env that the .envrc loads.
In direnv’s standard library, dotenv calls
watch_file on the .env, which reloads when the
file changes. It does not call require_allowed, the
function that
makes direnv ask for direnv allow again when a file
changes. I read that in the stdlib
source. direnv can also load a bare .env with no
.envrc if you set load_dotenv = true in its
config. I do not use that mode, and this post is about the
.envrc with dotenv setup.
The practical consequence: direnv allow is a statement
that you trust this directory’s .env as it exists right
now, and later edits to it ride on that approval. If a teammate’s pull
request or your next git pull changes .env,
the new values load without a prompt.
Before the first direnv allow in a cloned repo, I open
its .env and read it. It is a short file. Look for anything
that is not a plain value, and for variable names you would not expect
an app to set. If the repo does not commit its .env at all,
which is the better practice, you are only loading your own file and
this concern mostly goes away.
Why this matters more with coding agents
A coding agent clones repos, opens shells, and runs commands in them
all day. A .env file in a repo it just cloned is untrusted
input, the same as it is for you, except nobody is reading it first.
I have not seen an agent do this. If an agent’s shell runs
source .env after a clone, the file’s author chose what
ran. If your own shell has a function that loads .env and
the agent calls it, the same is true. The agent runs commands as you,
with whatever credentials your shell has. Putting the approval step in
direnv puts a human decision in front of the load, and it works for the
person who typed the cd and for the agent that did.
There is a related control I wrote about separately. The
PreToolUse hook in four Claude Code hooks I run
denies agent edits to .env. That hook protects the file
from being rewritten. This post is about what happens when the file is
read, so the two do different jobs and I run both.
For where the secrets themselves should live once they leave
.env, see AI
agent secrets management, and for the production side, runtime secrets on Fly with
Phoenix.
Set it up this way
If you already have direnv, per project:
cd your-repo
less .env # read it first
echo dotenv > .envrc
direnv allowIf you do not have direnv, install it from your package manager and add the hook line from the direnv hook docs to your shell’s rc file. I keep that line in my dotfiles, guarded so a machine without direnv skips it. I covered how that setup travels between computers in what to sync across machines for Claude Code.
Add .envrc to your global gitignore if you do not want
to commit it, or commit it if the team uses the same setup. Either way,
keep it to dotenv and nothing clever. An
.envrc is also a shell script, so it deserves the same
suspicion as a .env when it comes from somebody else.
What I would tell a startup team
This is a small control. Engineers clone things all day, and the path
from a repository to a shell on a laptop that holds cloud keys is short.
source .env is one of the shortest. If you want a second
set of eyes on how your team handles that path and the rest of the
environment around your agents, that is the work I do as a fractional CISO and CTO for AI startups.
The short version, if you take nothing else: stop sourcing
.env files you did not write, put dotenv in an
.envrc, and read the .env before you run
direnv allow the first time.