Environment variables are a legacy mess: Let's dive deep into them
- On Linux systems, any user process can inspect any other process of that same user for it's environment variables. We can argue about threat model but, especially for a developer's system, there are A LOT of processes running as the same user as the developer.
- IMO, this has become an even more pronounced problem with the popularity of running non-containerized LLM agents in the same user space as the developer's primary OS user. It's a secret exfiltration exploiter's dream.
- Environment variables are usually passed down to other spawned processes instead of being contained to the primary process which is often the only one that needs it.
- Systemd exposes unit environment variables to all system clients through DBUS and warns against using environment variables for secrets[1]. I believe this means non-root users have access to environment variables set for root-only units/services. I could be wrong, I haven't tested it yet. But if this is the case, I bet that's a HUGE surprise to many system admins.
I think ephemeral file sharing between a secret managing process (e.g. 1Password's op cli tool) and the process that needs the secrets (flask, terraform, etc.) is likely the only solution that keeps secrets out of files and also out of environment variables. This is how Systemd's credentials system works. But it's far from widely supported.
Any good solutions for passing secrets around that don't involve environment variables or regular plain text files?
Edit: I think 1Password's op client has a good start in that each new "session" requires my authorization. So I can enable that tool for a cli sessions where I need some secrets but a rogue process that just tries to use the `op` binary isn't going to get to piggyback on that authorization. I'd get a new popup. But this is only step 1. Step 2 is...how to share that secret with the process that needs it and we are now back to the discussion above.
1: https://www.freedesktop.org/software/systemd/man/latest/syst...
commit b409e578d9a4ec95913e06d8fea2a33f1754ea69
Author: Cong Wang <xiyou.wangcong@gmail.com>
Date: Thu May 31 16:26:17 2012 -0700
proc: clean up /proc/<pid>/environ handling
You can't read another process's environment unless you can ptrace-read the process, and if you can ptrace-read the process you know all its secrets anyway.cmdline is a different story.
And that the right thing to do if you want to harden your system is to disallow ptrace-read, and not bother changing software that uses environment variables?
Because I think most people that just try will be able to read the variables of any process on their computer.
If you want to prevent other processes from peeking into your process, run it under a different uid. Again. that's the point. A bunch of good software does that, running only small privileged bits in separate processes, and running some / bulk of the processes under an uid with severely limited privileges. See e.g. Postfix MTA, or the typical API server + reverse proxy split.
I don't think that this part of Unix is somehow problematic, or needs changing.
Linux has some ways to accomplish this, for example:
- seccomp. It can be done quite securely, but running general purpose software in seccomp is not necessarily a good way to prevent it from acting like the running user.
- Namespaces. Unless user namespaces are in use, only root can set this up. With user namespaces, anyone can do it, but the attack surface against the kernel is huge, mount namespaces are somewhat porous (infamously, /proc/self/exe is a hole), and they can be a bit resource-intensive, especially if you want to use network namespaces for anything interesting. Also, user namespaces have kind of obnoxious interactions with ordinary filesystem uids (if I'm going to run some complex containerized stack with access to a specific directory within my home directory, who should own the files in there, and how do I achieve that without root's help?). And userns without subuid doesn't isolate very strongly, and subuid need's root's help.
- Landlock. Kind of cool, kind of limited.
- Tools like Yama. On Ubuntu, with Yama enabled (the default), programs can't generally ptrace (or read /proc/self/environ) from other programs running as the same user.
In any case, once you've done something to prevent a process from accessing /proc/fd/PID for other pids belonging to the same user (e.g. pidns) and/or restricted ptrace (e.g. PR_SET_DUMPABLE), then environ gets protected.
Namespaces, if done incorrectly, can significantly increase the attack surface of the entire system (mount namespaces especially need to be treated with care) and same with regards to anything to do with user accounts.
The real security barrier on most operating systems to date is the user account and if you want full isolation (modulo kernel bugs), run a process as a separate user if you're that concerned about leakage (or ideally, don't run it at all).
It's not your (unprivileged user account's) place to decide the security posture of the entire system, that's why you're running into issues with root.
Tell that to literally any unprivileged user who would like to run any sort of software without exposing their entire account to any possible code execution bug or malicious code in the software they're running.
The real security barrier on most operating systems to date is the user account and if you want full isolation (modulo kernel bugs), run a process as a separate user if you're that concerned about leakage (or ideally, don't run it at all).
That a very 1980s or maybe 1990s perspective, I think. It's 2025. I have some computers. They are mine. I trust myself to administer them and do pretty much anything to them, but I would prefer not to trust all my software. Sure, I can, very awkwardly, try to isolate things by using multiple uids, but it's extremely awkward and I see no technical reason why uids are a good solution here.
And there are no shortage of technical reasons why uids are a horrible solution. I would like to run unprivileged software and delegate some degree of privilege to them -- imagine a web browser or an IDE or whatever. I, as the owner of the machine, can make a uid. But I would also like my IDE to isolate various things it runs from each other and from the IDE as a whole, and it cannot do that because it does not own the machine and should absolutely not be running as root.
On Linux you have some combination of Landlock, AppArmor, SELinux, calling prctl(PR_SET_NO_NEW_PRIVS), and the kitchen sink. On FreeBSD you have capsicum. Windows has integrity labeling + a bunch of stuff related to Job objects + a few things to disable win32k.sys calls.
But these are helpful and shouldn't be considered a panacea. The expectation is that you're delegating authority to a computer program to perform a certain task. Do computer programs abuse that authority sometimes? Absolutely. But nonetheless that's the fundamental model of most computer security, thanks in part to its usefulness.
Namespaces, to my mind, are a huge help, starting from trivial chroot and nsenter, all the way to bubblewrap[2] and containers (rootless, of course).
Also, with a properly prepared disk image, and the OS state frozen when everything heavyweight has started already, you can spawn a full-blown VM in well under a second, and run a questionable binary in it.
It would be fun to have "domains" within a user account, with their own views of the file system, network, process tree, etc, and an easy way to interactively run programs in different domains. Again, it's pretty doable; whoever creates an ergonomic product to do it, will win! (Well, not really, because most developers sadly run macOS, which lacks all these niceties, but has its own mechanisms, not very accessible to the user.)
And in the case of allowing setuid and filesystem views, the issue here becomes that an unprivileged user could create a view of the filesystem that has an /etc/passwd and /etc/shadow file that the attacker knows in their home directory. Run some setuid program (like su or sudo) with this view and we've successfully became root, breaking out of the sandbox.
And you can't whitelist /etc/passwd or whatever either. This is why allowing anyone to play with mount points is fraught with danger.
Now is suid/sgid a fundamental part of a Unix-like system? No, but setuid was created in the world that was and even though these are arguably bugs, releasing a Linux kernel that creates a bunch of security holes in userspace is a very very bad breakage of userspace.
---
No New Privileges does make this a bit better (as you can never gain more privileges than you already have, and this is a one-way door) and the kernel docs even say that eventually unshare or chroot may be allowed when operating under no new privileges
But this is currently why you can't chroot as an unprivileged user as you can trivially blow through the security domain on most Linux distributions
I had always assumed getting secrets from a running process' memory was non-trivial and/or required root permissions but maybe that's a bad assumption.
However, reading a process' environment is as trivial as:
`cat /proc/123456/environ`
as long as it's ran by the same user. No ptrace-read required.
By default, on most distributions, a user has PTRACE_ATTACH to all processes owned by it. This can be configured with ptrace_scope:
Environment variables are often used to pass secrets around. But, despite its ubiquity, I believe that's a bad practice:
I think environment variables are recommended to pass configuration parameters, and also secrets, in containerized applications managed by container orchestration systems.
By design, other processes cannot inspect what environment variables are running in a container.
Also, environment variables are passed to child processes because, by design, the goal is to run child processes in the same environment (i.e., same values) as the parent process, or with minor tweaks. Also, the process that spawns child processes is the one responsible for set it's environment variables, which means it already has at least read access to those secrets.
All in all I think all your concerns are based on specious reasoning, but I'll gladly discuss them in finer detail.
Let's say, as a developer, I need to do some API interactions with GitHub. So, in a terminal, using 1Password's cli tool `op`, I load my GH API token and pass it to my Python script that is doing the API calls.
Presumably, the reason I use that process is because I want to keep that token's exposure isolated to that script and I don't want the token exposed on the filesystem in plaintext. There is no reason for every process running as my user on my laptop to have access to that token.
But, that's not how things work. The Claude agent running in a different CLI session (as an example) also now has access to my GitHub token. Or, any extension I've ever loaded in VS Code also has access. Etc.
It's better than having it in plain text on the file system but not by much.
Additionally, unless I've misunderstood the systemd docs, if you are passing secrets through environment variables using unit's `Environment` config, ANY USER on a server can read those values. So now, we don't even have user isolation in effect.
My reasoning is pretty plain. It could be wrong, but it's hardly specious.
The Claude agent running in a different CLI session (as an example) also now has access to my GitHub token. Or, any extension I've ever loaded in VS Code also has access.
If you're giving untrusted software full access to your system, you're not in a position to complain about the system not being secure enough. Security starts by making mindful decisions according to your threat model. No system can keep you secure from yourself. I mean, it can, but it wouldn't be a system you have control over (see Apple, etc.).
There are many solutions that can mitigate the risks you mention. They might not be as convenient as what you're doing now, but they exist. You can also choose to not use software that siphons your data, or exposes you to countless vulnerabilities, but that's a separate matter.
By design, other processes cannot inspect what environment variables are running in a container.
That’s not exactly true. If a process is running in a container, and someone is running bash outside of that container, reading that processes environment variables is as simple as “cat /proc/<pid>/environ”. If you meant that someone in one container cannot inspect the environment variables of a process running in a different container, that’s more true. That said, containers should not be considered a security boundary in the same way a hypervisor is.
(inb4: container env-vars are isolated from other containers, not from processes on the host system)
What're you saying about privilege escalation? I don't see how a user namespace does not prevent/limit privilege escalation.
More than that, I'm interested if there is some broader consensus I'm missing on the shortcomings of namespaces.
I don't see how a user namespace does not prevent/limit privilege escalation.
They only prevent a single class of privilege escalation from setuid usage within the namespace. You can still obtain root using race conditions, heap overflows, side-channels, etc. or by coordinating with something outside of the namespace like a typical chroot escape.
Here's an old (patched) example of escaping the sandbox:
https://lwn.net/Articles/543273/
More than that, I'm interested if there is some broader consensus I'm missing on the shortcomings of namespaces.
The consensus is that they're not security features on Linux. I'm not sure who sold you on the idea that they were, because that was not handed down by the kernel devs.
On Linux systems, any user process can inspect any other process of that same user for it's environment variables. We can argue about threat model but, especially for a developer's system, there are A LOT of processes running as the same user as the developer.
This is a very good point I'd never realised! I'm not sure how you get around it, though, as if that program can even find a credential and decrypt a file, if it runs as the user then everything else can go find that credential as well.
The classical alternative has been to store (FTP) credentials in a .netrc file (also used by curl).
I have some custom code to pull passwords out of a SQLite database.
For people who are really concerned with this, a "secrecy manger" is more appropriate, such as Cyberark conjur and summon, or Hashicorp Vault.
That being said, I still use env vars and don't plan on stopping. I just haven't (yet?) seen any exploits or threat models relating to it that would keep me up at night.
Any good solutions for passing secrets around that don't involve environment variables or regular plain text files?
memfd_secret comes to mind https://man7.org/linux/man-pages/man2/memfd_secret.2.html
I haven't seen much language support for it, though. On one part maybe because it's Linux only.
People that write in Rust (and maybe Go, depends how easy FFI is) should give it a try.
I wanted for a time to get some support for it in PHP, since wrapping a C function should be easy, but the thought of having to also modify php-fpm put a dent in that. I can't and don't want to hack on C code.
In practice it'd be great if the process manager spawn children after opening a secret file descriptor, and pass those on. Not in visible memory, not in /proc/*/environ
You should be able to build up a nice capability model to get access to those memfds from daemon too rather than having to spawn out of a process manager if that model fits your use case a bit better.
Something like: my_secret = create_secret(value)
Then ideally it's an opaque value from that point on
As far as high-level language constructs go, there were similarish things like SecureString (in .NET) or GuardedString (in Java), although as best as I can tell they're relatively unused mostly because the ergonomics around them make them pretty annoying to use.
- On Linux systems, any user process can inspect any other process of that same user for it's environment variables. We can argue about threat model but, especially for a developer's system, there are A LOT of processes running as the same user as the developer.
Here's the thing though, the security model of most operating systems means that running a process as a user is acting as that user. There are some caveats (FreeBSD has capsicum, Linux has landlock, SELinux, AppArmor, Windows has integrity labels), but in the general case, if you can convince someone to run something, the program has delegated authority to act on behalf of that user. (And some user accounts on many systems may have authority to impersonate others.)
While it's by no means the only security model (there exists fully capability based operating systems out there), it's the security model that is used for most forms of computing, for better or worse. One of the consequences of this is that you can control anything within your domain. I can kill my own processes, put them to sleep, and most importantly for this, debug them. Anything I own that has secrets can grab them my ptrace/process_vm_readv/ReadProcessMemory/etc.
Any good solutions for passing secrets around that don't involve environment variables or regular plain text files?
Plain text files are fine, it's the permissions on that file that is a problem.
The best way is to be in control of the source to the program you want to run then you can make a change to always protect against leaking secrets by starting the program as a user that can read the secrets field. After startup the program reads the whole file and immediately drops privileges by switching to a user that cannot read the secrets file.
Works for more than just secrets too.
Any good solutions for passing secrets around that don't involve environment variables or regular plain text files?
Honestly, my answer is still systemd-creds. It's easy to use and avoids the problem that plain environment variables have. It's a few years old by now, should be available on popular distros. Although credential support for user-level systemd services was added just a few weeks ago.
A TL;DR example of systemd-creds for anyone reading this:
# Run the initial setup
systemd-creds setup
# This dir should have permissions set to 700 (rwx------).
credstore_dir=/etc/credstore.encrypted
# For user-level services:
# credstore_dir="$HOME/.config/credstore.encrypted"
# Set the secret.
secret=$(systemd-ask-password -n)
# Encrypt the secret.
# For user-level services, add `--user --uid uidhere`.
# A TPM2 chip is used for encryption by default if available.
echo "$secret" | systemd-creds encrypt \
--name mypw - "$credstore_dir/mypw.cred"
chmod 600 "$credstore_dir/mypw.cred"
You can now configure your unit file, e.g.: [Service]
LoadCredentialEncrypted=mypw:/etc/credstore.encrypted/mypw.cred
The process you start in the service will then be able to read the decrypted credential from the ephemeral file `$CREDENTIALS_DIR/mypw`. The environment variable is set automatically by systemd. You can also use the command `systemd-creds cat mypw` to get the value in a shell script.At least systemd v250 is required for this. v258 for user-level service support.
If you need to keep secrets from other processes—don't run them under the same user account. Or access them remotely, although that brings other tradeoffs and difficulties.
Anything involving vaults where Application reaches out to specific secret vault like Hashicorp Vault/OpenBao/Secrets Manager quickly becomes massive vendor lock in where replacement is very difficult due to library replacement and makes vault uptime extremely important. This puts Ops in extremely difficult place when it becomes time to upgrade or do maintenance.
Config files have problem of you have secrets, how do you get them into config file since config files are generally kept in public systems? Most of the time it's some form of either "Template replacement by privileged system before handing it to application" or "Entire Config File gets loaded into secret vault and passed into application". Templating can be error prone and loading entire config files into secret manager is frustrating as well since someone could screw up the load.
Speaking of config files, since most systems are running containers, and unless you are at Ops discipline company, these config files are never in the right place, it becomes error prone for Ops to screw up the mounting. Also, whatever format you use, JSON/YAML/TOML is ripe for some weird config file bug to emerge like Norway problem in YAML.
Getting secrets from Kubernetes Secrets API I've seen done but lock in again. I'd strongly not recommend this approach unless you are designing a Kubernetes operator or other such type system.
I will say I've seen Subprocess thing bite people but I've seen less and less subprocess generation these days. Most teams go with message bus type system instead of sub processing since it's more robust and allows independent scaling.
Then, on the backend, you can configure etcd to use whatever KMS provider you like for encryption.
Yes, you can mount Secrets as Volumes or Env Var in Kubernetes which is fine but I'm not talking about "How you get env var/secret" but "Methods of dealing with config."
I’ve seen Applications that do direct calls to Kubernetes API and retrieve the secret from it. So they have custom role with bindings and service account and Kubernetes client libraries.
You'd be forgiven for being mistaken however, because this encryption is handled in a way that's transparent to the application.
If you're talking about your application making a call to the k8s api server, then you shouldn't do that unless you're developing a plugin. The kubelet knows how to retrieve and mount secrets from the k8s api server and display them as environment variables to the application. You just declare it as a part of your deployment in the podspec.
Bit more of an initial hurdle than "just run the docker image"; however.
However, most of time, Devs don't need to develop on Kubernetes since it's just Container Runtime and Networking Layer they don't care about. They run container, they connect to HTTP endpoint to talk to other containers, they are happy. Details are left to us Ops people.
FWIW, Skaffold.dev is similar to Tilt, and has been working out great. "skaffold dev" on the cli or the corresponding button in the users IDE starts up a local kube cluster (minikube), the dev code in a container, any other configured containers, optionally opening a port to attach a debugger, and watches the code for changes and restarts the container with the dev code when there's changes. Developers aren't beholder to the capacity of whoever's on call on the ops team to manage the containers they need to be productive. The details of pods and CRDs and helm and ArgoCD and kubectl are abstracted away for them. They just run "skaffold dev" and edit code. Ops gets to run "skaffold render" to build and tag images, and generate the corresponding kubernetes manifest.
Kubernetes is massive beast and I get it. It feels extremely overcomplicated for "Please for the love of all that holy, just run this container." However, trying to abstract away such complexity is like trying to use Golang with some Python to Golang cross compiler. It works until it you need some feature and then oh god, all hell breaks loose.
I have not played with scaffold either but I will say. scaffold render should not be Ops job, I find it goes best when Devs present artifact they believe is ready for production and I can slot into the system. Otherwise, the friction between Devs handing Ops what they think is possibly buildable artifact quickly becomes untenable.
Keep it simple and design your applications so they’re agnostic to that fact.
It’s really not that hard, I’ve been doing this for at least 6 or 7 years. A little bit of good engineering goes a long way!
So Command Line leaks worse than Env Var.
Config file, see original post for problems.
Env Var, see blog for problems.
For example mbsync/isync does this.
String manipulating is one of those "This is easy" until it's not.
I don't mean to hardcode secrets into the config file either. I was suggesting to put a command into the configuration file that the application then calls to get the secret. This way the secret is only ever passed over a file descriptor between two processes.
I still think this is worse than config file/Env Vars.
When we moved it to Vault, it was seamless. Just meant adding our Vault backend wrapper as a dependency and updating the config to use the Vault backend.
This is why I continue to use env vars and dotenv for configuration. They are extremely simple, work well, and are compatible with secrets managers and other secrets tooling.
Though lately I've been veering into sOps the last few years. YAML is just so nice for expressing how an app should be configured, and sops makes encrypting chunks of it so easy. Dealing with GPG keys can be challenging though, which Vault/OpenBao solve, but then lock-in becomes an issue (though less so with OpenBao).
Getting secrets from Kubernetes Secrets API I've seen done but lock in again.
I don't get this part. Why would you use Kubernetes secrets alone for storing secrets if they are not encrypted by default? Using k8s secrets only makes sense if (0) you are using k8s, (1) you already did some groundwork like encryption at rest for the control plane, and (2) you are actually using a solution like Secrets Store CSI Driver (or alternatives - just don't depend on k8s secret being actually more secret than a ConfigMap by default).
And then, it's the opposite of lock-in as the Secrets Store CSI Driver uspports multiple backends, including open source Conjur.
The reason is that `getenv()` hands out raw pointers to the variables, so overwriting a variable using `setenv()` is impossible to guard against. Treat with extreme caution.
ps wwwex | grep [w]wwex
31109 pts/0 R+ 0:00 ps wwwex GDM_LANG=en_GB.utf8 STARSHIP_SHELL=fish GDMSESSION=xfce STARSHIP_SESSION_KEY=2904922223926273 XDG_CURRENT_DESKTOP=XFCE LC_NUMERIC=en_GB.UTF-8 TERMINFO=/usr/share/terminfo LC_MONETARY=en_GB.UTF-8 SHELL=/bin/fish LC_ADDRESS=en_GB.UTF-8 ... many, many more ...
I think that would still be better than env vars, which are more likely to leak somewhere you didn't intend them to.
In contrast, arguments are an ordered sequence of strings, so are more suited for present/not-present flags (e.g. --dry-run) , or listing a variable number of values (e.g. filenames). Using arguments for name=value data either requires individual strings to be parsed into components, or requires a pair of name and value strings to appear sequentially (without overlapping another pair, etc.). Arguments also tend to require disambiguation, e.g. prepending names with `--`, to account for optional arguments not being present, or for allowing options to be given in any order, etc. That may also require escape hatches, like a standalone `--` argument, in case user data conflicts with such reserved patterns, etc.
Sure, there are libraries for parsing this stuff; and conventions that we can get used to (except when a tool doesn't follow them...), but it's still a complicated mess compared to `NAME1=val1 NAME2=val2 my-command --flag1 --flag2 file1 file2 file3`
FreeBSD does the same. See here for discussion: https://freebsd-current.freebsd.narkive.com/NwqZQDWm/fix-for...
For a lot of applications that's the right call given the rest of the posix semantics you're constrained to and the kinds and frequency of data you pass via env vars.
execve seems like the preferable choice on a lot of grounds.
frequency of data
Practically, if you're moving enough data through setenv that the memory leaked versus the steady state fluctuation of the program is at all visible, you've got much bigger problems.
Getenv in a program without setenv is fine in both implementations. Setenv is unusable with all conforming implementations.
To pass environments to children, use execve.
The Linux behavior allows a careful single threaded program to use setenv correctly. The BSD/Solaris behavior makes all usage incorrect, but the incorrectness comes in the form of a memory leak, which is preferable to a security issue, usually.
There's no correct, portable use of setenv. If you call it, it's a bug.
Arguably that's less harmful than the thread-safety issues on Linux, but there's no perfect solution here as long as POSIX stays what it is.
[0] https://man.netbsd.org/getenv_r.3
[1] https://github.com/freebsd/freebsd-src/commit/873420ca1e6e8a...
You still don’t know if some library you use calls getenv()
Well, you can know, if you check their imported symbols. (I suppose they could be getting it via dlsym, but what are the odds of that...)
So if getenv_r() is added to the C library, and over time third party libraries started adopting it, you could get to the point that you could know no code in your process is calling getenv(), because none of the libraries your process loads import that symbol.
They could even add a glibc tunable to make getenv() call abort()... then you could be very sure nobody is calling getenv(), because if anyone ever did, boom
Contribute to a clean environment! Don't use environment variables for configuration! </rant>
You should never pass secrets on the command line.
"Wow, I really enjoyed writing this… …and I hope it wasn’t a boring read."
No, it was very interesting actually!
An excellent deep-dive into the murky area of Unix/Linux environment variables, how they are set, how they are passed, what's really going on behind the scenes.
Basically a must-read for any present or future OS designer...
Observation (if I might!) -- environment variables are to programs (especially chains of programs, parent processes, sub processes and sub-sub processes, etc.) what parameters are to functions -- and/or what command-line parameters are... they're sort of like global variables that get passed around a lot...
They also can influence program behavior -- and thus the determinism of a program -- and thus the determinism of a chain of programs...
Phrased another way -- software that works on one developer's machine might not work on another developer's machine and/or in a production environment because one crucial environment variable was either set or not set, or set to the wrong value...
(NixOS seems to understand this pretty well... that "hermetically sealing" or closing or "performing a closure around" (that's probably slightly the wrong language/terminology in "Nix-speak" but bear with me!) a software environment, including the environment variables is the way to create deterministic software builds that run on any machine... but here I'm digressing...)
But my main point: I complete agree with the article's author -- environment variables are a legacy mess!
Although, if we think about it, environment variables (if we broaden the definition) are a sub-pattern of anything that affects the state of an individual machine or runtime environment -- in other words, things such as the Windows Registry, at least the global aspects of it -- are also in the same category.
Future OS's, when they offer environments for programs or chains of programs to run -- should be completely containerized -- that is, the view of the system -- what data/settings/environment variables/registry variables/files/syscalls/global variables it has access to -- should be completely determinable by the user, completely logabble, completely transparent, and completely able to be compared, one environment to another.
In this way, software that either a) fails to work at all b) works in a non-deterministic way -- can be more easily debugged/diagnosed/fixed (I'm looking at you, future AI's that will assist humans in doing this!) -- then if all of that information is in various different places, broken, and/or opaque...
To reiterate:
"Wow, I really enjoyed writing this… …and I hope it wasn’t a boring read."
No, I really enjoyed reading it(!), it's a brilliant article, and thank you for writing it! :-)
Upvoted and favorited!
environment variables are to programs (especially chains of programs, parent processes, sub processes and sub-sub processes, etc.) what parameters are to functions -- and/or what command-line parameters are... they're sort of like global variables that get passed around a lot...
They're not globals, since they're copied into sub-processes; so mutation doesn't propagate upwards.
My own opinion is that environment variables are dynamically scoped keyword arguments http://www.chriswarbo.net/blog/2021-04-08-env_vars.html
They're not globals, since they're copied into sub-processes; so mutation doesn't propagate upwards.
If we broaden our thinking to look at a computer process as a mathematical function, i.e., if we think about a computer process as
y = f(x)
Where y is the behavior of the function (what final state it results in / resolves to), f is the process itself, and x is the parameter or list of parameters that are passed to f, then if that process reads and subsequently alters its behavior in relation to any environment variable, then we should no longer think of that function as merely y = f(x), but as
y = f(x, z)
where Z is the set of environment variables that are consumed by, and subsequently alter the behavior of f, resulting in a different y.
Now not all processes read environment variables and alter their behaviors because of an environment variable being set.
That is true.
Those processes remain as y = f(x).
But if a process does read and does act on an environment variable (usually without the end-users' knowledge, because how many users keep track of which programs read/act on -- which environment variables?), then then this the same as passing extra parameters -- y = f(x, z) -- to the function!
(Sub-observation: A future OS would have an API call to granularly read a single named environment variable at a time (not the entire block of them at once!), and use of this call could be logged and sorted by program, and there would be a user-settable control to granularly determine which environment variables could be read by which programs/processes...)
They're not globals, since they're copied into sub-processes; so mutation doesn't propagate upwards.
Mutation or non-mutation upwards -- is not the issue.
The issue is: If users run program f and they pass it various command line parameters x, and they want y, then if environment variables are present and if they alter the behavior of that program, then what the users are really getting is y = f(x, z), which may not be the behavior/result they want, because z influences the behavior, and is passed in a not-really-all-that-transparent manner (most people usually don't check, log or modify environment variables nor account for them in the determinism of their programs -- unless something is broken...)
Phrased another way -- a future OS would have some way of logging everything, all state information (including environment variables which includes registry settings) -- that go into any given program.
Now maybe environment variables aren't "globals" in the strictest definition...
But let's see...
In most modern operating systems as of 2025, environment variables can easily be read by most programs, functions/procedures/methods inside of those programs, sub-functions/sub-procedures/sub-methods of those programs, etc., etc., etc.
Once they can be read... they can become the state of one or more variables in that program its functions, sub-functions, etc.
And once it can become the state of those one or more internal variables, the program can alter its behavior / result -- based on them.
Global variables -- can do the same exact same thing to a program.
So I'll leave it as a linguistic/semantic debate to future readers, mathematicians, programmers and OS designers -- as to whether or not environment variables (and related globally readable objects such as the Windows Registry) are global variables...
(You are very much correct about environment variables -- if they are mutated (aka written to/overwritten) by by a sub-process, then that mutation doesn't typically propapagate upwards the parent/creator process chain -- but perhaps in the context of my discussion, I am interested/concerned about -- the global readability/accessibility of environment variables... But you are very much correct in your statement!)
I think the key point that I am trying to make is that more transparency/insight/logging could always be had as to where exactly programs get ALL of their inputs from (this includes environment variables, this includes API calls which may differ machine to machine, etc., etc.), and what average end users are made aware of...
My point is that environment variables are "dynamic variables" (AKA they are "dynamically bound", AKA they have "dynamic scope" https://en.wikipedia.org/wiki/Scope_(computer_science)#Dynam... ). That is very much not like globals.
For example, consider the following script:
#!/usr/bin/env bash
# The export keyword turns a shell variable into an env var
export foo='hello'
echo "BEFORE '$foo'"
# Invoke the env command as a subprocess, to list all of the
# environment variables it's inherited. Filter it using grep.
foo='goodbye' env | grep 'foo='
echo "AFTER '$foo'"
Our hypothesis will be that env vars are "globals", i.e. that there's a place in memory that all these occurrences of the name `foo` are referring to. Under this hypothesis, there are several plausible outputs that the above script might give:Perhaps the `foo='hello'` assignment sets the memory referred to by foo to the value `hello`; then the `foo='goodbye'` assignment sets that memory to `goodbye`. In which case we'd expect to see:
BEFORE 'hello'
foo=goodbye
AFTER 'goodbye'
On the other hand, perhaps the `foo='goodbye'` assignment fails (maybe since that memory already contains the value `hello`?), in which case we would expect to see something like: BEFORE 'hello'
foo=hello
AFTER 'hello'
It might even be the case that some obscure issue causes both assignments to fail; but, by sheer coincidence, the memory `foo` is referring to just-so-happens to already contain the value `goodbye`. In that case, we'd expect to see: BEFORE 'goodbye'
foo=goodbye
AFTER 'goodbye'
Now, let's test our hypothesis by performing the experiment, i.e. by executing the script: BEFORE 'hello'
foo=goodbye
AFTER 'hello'
Uh oh, that doesn't correspond to any of the possibilities I gave! With a little more thought, we might see that this output cannot be produced using a single memory location; since the value `hello` was not "forgotten", even though foo had the value `goodbye`.The answer is that env vars are not globals; instead, they are "dynamic variables". Normally, programming languages implement dynamic variables internally by traversing the stack; e.g. in Lisp it would look something like:
(let ((foo "hello"))
(echo (format "BEFORE '%s'" foo))
(let ((foo "goodbye"))
(echo (filter (has-prefix "foo=")
(something-like-the-locals-function-in-python)))
(echo (format "AFTER '%s'" foo)))
However, that wouldn't work across process boundaries; which is why env vars get copied (perhaps with additions/removals) when subprocesses are created.(I go into more detail in the blog post I linked ;) )
PS: You may be wondering why I wrote a script containing `foo='goodbye'` if I previously said "Forget mutation". That is because we are not mutating the value of `foo`; we are entering a new scope, where `foo` has a different value; but the old scope with the old value still exists; we saw as much when it outputs `AFTER 'hello'`. Similar to how in "lexical scope" (a more common form of scoping, which is different from global scope and from dynamic scope) we can write a whole bunch of functions with arguments called `x`, but that doesn't count as mutating the value of `x`. Or we can even call the same function, perhaps recursively, with different values for the same argument; but those new values are not mutations of the old ones, despite them having the same name and being defined in the same place (like dynamic scope, lexical scope is also typically implemented within a process by using stack frames).
but the usual procedures you find online stops working once you reboot (or close the terminal I think?)
The environment isn't persistent between sessions. That means you need to make the change in a way that runs on every new session (login or new terminal window).
Depending on how your system is configured:
.bash_profile gets run once on every login
.bashrc gets run once on every non-login new session (i.e. a new terminal window)
It's typical, if using these files, to do something like this:
if [ -f ~/.bashrc ]; then
source ~/.bashrc
fi
in the .bash_profile file, putting most other things in .bashrc, such that you don't have to worry about the distinction.If you're not even using bash or bash-likes at all, but instead something like Zsh, fish, etc you'll need to set things the way they want to be set there.
They should add a simple env var GUI like Windows has that just works, and isn't terminal-specific
This doesn't exist in linux, because there isn't "one place" that all possible terminals draw from. Conceivably, it's possible to write a GUI tool that reads your .bashrc file, looks for anything that resembles a variable, parses the bash code to look for ways it is set, and then present a GUI tool that could update it in common cases, but... it's way easier to just write the change in a text editor.
NixOS & home-manager do have functions that can set environment variables across all shells enabled via either of those systems. So I've got `programs.bash.enable = true;` && `programs.fish.enable = true;` && `home.sessionVariables = { EDITOR = "nvim"; };` in my home-manager config for example. A GUI editor for that would certainly be possible.
Conceivably, it's possible to write a GUI tool that reads your .bashrc file
What's wrong with an envfile or envdir? The envdir is kind of annoying but at least you can set permissions on the files inside it.
The application restarting part can't really be fixed, since environment variables aren't ever injected to a running process and can only be changed by the process itself (terms and conditions may apply) and even changes during runtime could be ignored since the program itself may have cached some already computed value based on the variable.
[0] https://www.freedesktop.org/software/systemd/man/latest/envi...
This is much simpler than having to remember where the dang environment variables button is in "advanced system settings" or whatever awful command prompt syntax sets it permanently.
Situation: There are 14 competing ways to set environment variables on Linux
We should create a universal way that just works and isn't terminal specific to set environment variables!
Situation: There are 15 competing ways to set environment variables on Linux
At a past firm, I was trying to debug how a particular ENV var was getting set. I started out thinking it was something simple like the user's .bashrc or equivalent.
I quickly realized that there were roughly 10 "layers" of env var loadings happening where the first couple layers were:
- firm wide
- region
- business unit
- department
- team etc etc
I ended up having to turn on a bash debug flag so that I could see exactly where the var was getting set in the layer stack.
https://nodejs.org/api/cli.html#--trace-env
Since you can set/unset/modify env vars in so many ways with different APIs, this sounds super useful in complex debugging scenarios.
But as mentioned in the article it is just a global hashmap that can be cloned and extended for child processes. Maybe in 1979 it was a good design decision. Today it sometimes hurts.
For example, kubernetes by default literally pollutes the container’s environment with so-called service links. And you will have fun time debugging a broken application if any of those “default” env vars conflict with the env vars that your app might expect to see.
https://kubernetes.io/docs/tutorials/services/connect-applic...
They are ubiquitous and we are living in the world of neo-conservatism in IT where legacy corner cuts are treated as a standard and never challenged (hello /bin, /usr/bin, /lib, /usr/lib)[0]
pam-config -d --env
[0] Crowded elevator atrium. Multiple elevators running. Elevator wants to close, another one is coming (oh! I heard it "ding"!). Somebody is holding the elevator which wants to depart and trying to wave me in. Why doesn't somebody push them out?[1] I'm at a stop sign. Some complete idiot is trying to turn left onto the street I'm leaving and waving me to turn left in front of them. Fuck no! I turn in front of you, somebody rearends you, you fly forward into me: my fault! You should be able to make this turn, if you can't go around the block! [2]
[2] I could go out of my way and turn right. Or I can just wait and see what happens.
Posted from a productive Windows workstation.
In other languages, check that the var you pulled in isn’t falsey before proceeding.
In a shell script, set -u
Won't help. I'm _running a program_ want to configure it with environment variables, not _writing a program_ that I expect the user to configure for himself.
falsey
There are more than two programming languages in the world.
Argument list too long
It's absolutely crazy that this isn't a dynamically resizable vector.Environment variables are the "indefinite scope and dynamic extent" variable bindings to make structured programs out of Unix processes. To compare them to a text file is like writing that a program needs no variables because it can read all the values it needs from an input data file when it needs them.
Environment variables are precisely to be used in situation where sub-processes need to be passed information in a way that is not necessarily affecting subsequent calls to that subprocess from another section of a program. Sometime the subprocesses are sequences of nested shells, and sometimes other more complex programs, but the same idea applies.
> But in reality, not many applications use lowercase. The proper etiquette in software development is to use ALL_UPPERCASE
I always prefer lower case for env variables in scripts. Thanks for pointing out that it can help reduce clashes with standard tools.