Skip to content
shellhacks.dev
Shell Scripting

How to Read Secrets in a Shell Script Without Leaking Them

Read secrets in a shell script from files or stdin instead of arguments, and avoid leaks through ps, history, logs, xtrace and the environment.

6 min read
Summarize in AI
Illustration for "How to Read Secrets in a Shell Script Without Leaking Them": a shell script in an editor, a request travelling to a server, an exit status badge.

Most leaks of a script's secret are not clever attacks. They are an API key passed as an argument and seen in ps, a password in .bash_history, or a trace log uploaded with a failed CI job. The practices below are shaped by where a secret can be seen, and use Bash 5.2 and GNU tools; the wider checklist is in the shell script security checklist.

Do not put secrets in arguments

Shell
curl -H "Authorization: Bearer $TOKEN" https://api.example.com/v1/me

While that command runs, its full argument list is readable by any local user through ps and /proc/PID/cmdline, and it lands in the shell history if typed by hand. Pass the header on stdin or from a file instead:

Shell
curl -H @- https://api.example.com/v1/me <<<"Authorization: Bearer $TOKEN"

curl 7.55 and newer read headers from a file or stdin with -H @file, so the token never reaches the argument list. The here-string is built by the shell and handed over a pipe or temporary file. More on this class of leak is in secrets visible in the process list.

Read from a file with strict permissions

Shell
secret_file=${XDG_RUNTIME_DIR:-/run/user/$UID}/api.token
[[ -r $secret_file ]] || { echo "missing token file" >&2; exit 1; }
[[ $(stat -c %a "$secret_file") == 600 ]] || { echo "token file must be mode 600" >&2; exit 1; }
TOKEN=$(< "$secret_file")

Refusing to run when the file is group- or world-readable follows what ssh does with private keys. $(< file) reads the file without a subprocess. Create the file with umask 077 so it is never briefly readable by others.

Prompt without echo

Shell
read -rsp "Password: " PASS
echo

-s turns off echo and -r keeps backslashes literal. The value stays out of history and the argument list. Details are in reading a password in Bash.

Switch tracing off

Shell
{ set +x; } 2>/dev/null
TOKEN=$(< "$secret_file")

With set -x every expanded command is printed to stderr, secrets included. Turning it off before the sensitive line, and discarding the trace line for the set +x itself, keeps the value out of the log. CI systems such as GitHub Actions mask known secrets in output, but only the ones they were told about, so a derived value is not covered.

Where a secret can leak

A useful way to design handling is to list every place a value can appear while the script runs. The argument list is visible in the process table. The environment is visible to the same user and root in /proc/PID/environ, and inherited by every child. Shell history records typed commands. Trace output records expanded commands. Error messages from tools often echo the failing command line. Core dumps hold memory. Temporary files and editor swap files keep copies. Backups copy files that the script itself deleted. Each of these has a defence, and the script's job is to avoid giving a secret to any of them it does not need to. A variable read from a file and passed to a command on standard input touches only memory and a pipe.

Command substitution deserves attention. A line like echo "$TOKEN" | tool uses the shell built-in echo and a pipe, so the value is not in any argument list, while the same line with an external command in a different shell can behave differently. Bash's echo and printf are built-ins, so they do not appear in the process table. A here-string, tool <<< "$TOKEN", creates a temporary file in some shells, so prefer the pipe on shared machines.

Secret managers and short-lived credentials

A better answer than protecting a long-lived secret is to not have one. Cloud providers issue temporary credentials tied to the machine or job identity, such as instance roles and OIDC federation in CI, valid for minutes to hours and scoped to what the job needs. A leak then has a small window. Where a static secret is unavoidable, fetch it at run time from a manager such as Vault, a cloud secrets service, or the operating system keyring, using the job's own identity to authenticate, and keep it in memory. The command line tools of these services print a secret to standard output, so capturing it into a variable inside the script and never echoing it is the usual pattern.

For files, systemd offers LoadCredential=, which places a secret in a private in-memory directory for the unit and exposes it through $CREDENTIALS_DIRECTORY, an approach that avoids both the environment and a world-readable file. Containers have an equivalent with secrets mounted at /run/secrets.

Keeping secrets out of the repository

The most frequent leak is a commit. Once a token is in history, removing the line from the latest commit does nothing for clones that already exist, so rotation is the only fix. Prevent it with a pre-commit scanner such as gitleaks or git-secrets, and run the same scan in CI as a backstop, because hooks can be skipped. Keep example configuration files with placeholders like API_TOKEN=changeme, and add the real files to .gitignore from the first commit. When a leak happens, the response is the same each time: revoke, rotate, check the provider's audit log for use, and only then clean the history.

Logging and error output

A script that handles secrets should log what it did without logging what it used. Mask values in messages by printing only the last few characters when an identification is needed, and make sure a failing call does not print its request. With curl -v, headers including authorization are echoed to the terminal, so a debug flag turned on in CI leaks the token into the build log. Review the output of a run with the debug flag on before enabling it anywhere that stores logs, and search the result for the secret with grep -c as a final check.

A review checklist for a script that uses secrets

When reviewing, ask six questions. Where does each secret come from, and who can read that source? Does it ever appear in an argument, the environment of a child, a file with loose permissions or a log line? Is tracing disabled around it? What happens on failure, and does the error message include the value? How is it rotated, and how long does it live? And what would be needed to remove it from every place it was copied? A script that answers these in a short comment block at the top is easier to audit and shows that the author considered them. Add a test that runs the script with a fake secret and greps the captured output for it, failing the build if it appears.

Where this does not apply

Once a secret has been printed to a log, a shared terminal or a ticket, treat it as exposed and rotate it; deleting the line does not undo copies. Unsetting a variable with unset TOKEN removes it from the shell but not from memory dumps or from children that already inherited it. For tooling, a dedicated manager such as pass, gopass or age is better than a plain file, as in pass, gopass and age. The OWASP secrets management cheat sheet and the Bash set builtin reference are the primary sources for the rest.

Also inSecurity
Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.