Security for startups

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
  1. What source .env runs
  2. The old one-liner was also broken
  3. Parsing is not enough
  4. What I use now: direnv
  5. The gap you need to know about
  6. Why this matters more with coding agents
  7. Set it up this way
  8. What I would tell a startup team

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:

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 allow

dotenv 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 allow

If 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.