Handling secrets (somewhat) securely in shells

Posted on January 9, 2026 by Linus Heckemann

Sometimes, you need to deal with secrets in an interactive shell. Say, for example, you want to do things with the API of a GitLab instance for which you require authentication:

$ curl -fsSLH 'Authorization: Bearer 1s7zo2a-mzsLP6yAo2SM' https://gitlab.example.com/api/v4/projects

Oh no!

Process information leakage

By doing that, you’ve just made the token available to everything on your system that can see your processes! Process command lines are visible to all processes through /proc on most Linux distributions. This is how tools like ps and pgrep work on Linux – they walk through the per-process directories in /proc and read files describing the process, like stat or status and cmdline. You can use the hidepid mount option for the proc filesystem to prevent users from inspecting processes of other users.

macOS also hides other users’ processes by default.

However, many tools allow you to avoid passing secrets on the command line at all, and this is usually a better approach because you can apply it even on systems where you don’t have the necessary access to change mount options for /proc. In the curl example, you can write the header to a file and have curl read it from there instead of from the command line directly:

$ umask 077 # prevent the file from being readable for other users
# echo is a shell builtin, so it doesn't show up in the process table
$ echo 'Authorization: Bearer 1s7zo2a-mzsLP6yAo2SM' > auth-header
$ curl -fsSLH @auth-header https://gitlab.example.com/api/v4/projects

But Unix-like systems support fancy files that don’t behave like simple files, which lets you avoid actually storing the secret. Many shells support so-called “process substitution”, which launches a subshell and provides its output as a virtual file that doesn’t actually represent persistent storage, instead being a buffer which can only be read from once.

$ echo <(echo secret token)
/dev/fd/63
$ curl -fsSLH @<(echo 'Authorization: Bearer 1s7zo2a-mzsLP6yAo2SM') https://gitlab.example.com/api/v4/projects

This should prevent leakage of the token via the process table entirely.

So you’re done with your work and you exit your shell, and…

Shell history leakage

After going to all the effort of not putting the token in a file, your shell has helpfully saved then command you ran in your history file for all your processes to steal! One way to avoid this is to prevent the command from being written to history. Bash has a configuration variable named HISTCONTROL, which when set to include ignorespace prevents commands prefixed with whitespace from being saved in history. This is inconvenient though! History is really helpful for iterating on a command that you haven’t got quite right yet.

Fortunately, there’s another approach we can take here. Using a shell variable, we can avoid putting the secret in any shell commands directly:

$ token=1s7zo2a-mzsLP6yAo2SM
$ curl -fsSLH @<(echo "Authorization: Bearer $token") https://gitlab.example.com/api/v4/projects

But wait – the token= command ends up in the history again! Let’s try that again:

$ read -r token
1s7zo2a-mzsLP6yAo2SM
$ curl -fsSLH @<(echo "Authorization: Bearer $token") https://gitlab.example.com/api/v4/projects

Using read instead of setting the token directly in a command prevents the token from being saved in history, but keeps the command for it conveniently there. You can even add the -s option to the read command to prevent the token from being displayed on the screen as you type or paste it in.

Another approach that can be helpful here is getting the secret from the output of a command.

$ token=$(wl-paste || xsel -b || pbpaste) # get the token from the clipboard
$ token=$(rbw get gitlab-access-token) # get the token from a command-line password manager

This is more versatile and allows for some more convenient shortcuts than the read-based approach; it also works for secrets containing spaces or other characters that would cause read to split the input.

Why not environment?

You may have noticed that I set the variable using name=value rather than export name=value as is very commonly used. This is because export marks a variable as exported, i.e. stores it in the process environment, which is inherited by child processes. Putting secrets in environment variables is common but somewhat risky, because it makes the secrets available to all processes started from the environment – many of which have no business with them! This can result in the secrets being leaked, especially by accident, when programs dump all their environment variables into a log for debugging purposes or similar.

How much of a problem this is depends a lot on the use case. Programs that implement all of their functionality themselves are generally quite safe, since they don’t propagate their environment any further. However, other programs delegate functionality to other processes. This is good for many use cases! Git, for example, will invoke SSH when fetching from or pushing to a remote repository. You can use the PATH environment variable to influence where ssh is found, and replace it with a wrapper script that adds authentication behaviour or similar if all else fails1.

Some software invokes more complex systems of processes, however. For instance, Terraform launches a process for each configured provider. Each of these inherits secrets from the process environment, providing more room for accidental leakage. That’s why I try to avoid using environment variables for secrets when possible, preferring shell variables that aren’t inherited by child processes and have to be passed into the commands that need them explicitly.

Conclusion

The approach I take here may be described as somewhat paranoid, and the cost of actually forming the habit of handling secrets this way may be greater than the risk of leaking secrets in the ways I describe. That’s up to individuals (or company security policy authors) to evaluate for themselves. There are also many bases that I don’t cover and routes through which sufficiently-smart malware could easily still obtain the secrets I’m working with. But I definitely feel a lot more comfortable when secrets are never written to persistent unencrypted files, and being aware of these leakage vectors is helpful to avoid that!

In my personal opinion, the pitfalls involved are also a testament to how we probably should be using less Bash and preferring languages where the obvious way to do something is safer. That’s a pretty low bar, given that not many languages that we use on a daily basis are shells that function on the principle of executing processes for everything; but I’m also intrigued by the potential that type systems have for “tagging” secrets and preventing their propagation beyond where they’re needed.


  1. Git specifically has the configuration option core.sshCommand with which you can provide an alternative SSH, but a wrapper script can still be useful if you’re using other software that doesn’t make the SSH program configurable.↩︎