Never Leaving Secrets and Passwords in Bash History
AI generated
$_
#!/
Bash · Security · Secrets Management
Never Leaving Secrets and Passwords in Bash History
from HISTCONTROL to the process list

Passing a password as a command line argument leaves it permanently in Bash history, visible to anyone who later uses the same shell. This article shows how secrets end up in Bash history, the process list, and logs, how HISTCONTROL and safe handoff methods prevent that, and how to search existing history files for forgotten credentials.

18 min read HISTCONTROL · Secrets · Process List · Auditing Bash 4.x · 5.x · Linux

1. How secrets unintentionally end up in Bash history

Bash history stores every interactively entered command by default, including all arguments. Anyone who passes a password, an API key, or a token directly as a command line argument, for example mysql -u root -pSecretPassword, leaves that secret permanently in the file ~/.bash_history, often unnoticed for months or years. Anyone who later gains access to that file, whether through physical access, a compromised backup, or another user on the same system, can read the secret in plain text.

The problem gets worse in shared environments where multiple administrators use the same shell, or where bastion hosts serve as a central access point. Once stored in Bash history, a credential stays there until explicitly removed, and many teams only realize during a security audit how many secrets have accumulated in history files over years.

Bash scripts themselves can also leave secrets in history if they are executed interactively via source or assembled and tested in a shell before being written to a file. The basic rule is therefore: no secret should ever appear as a visible command line argument in an interactive shell, neither during manual testing nor in production Bash scripts.

2. HISTCONTROL, HISTIGNORE, and the leading space

Bash offers HISTCONTROL, a built in but often unused mechanism, to exclude certain commands from history. The value ignorespace ensures that any command starting with a space is not recorded in history. This is particularly useful for one off sensitive commands that need to be run interactively, such as setting an environment variable containing a secret. The value ignoredups additionally prevents immediately repeated commands from being stored multiple times, which makes history more compact but not necessarily safer.

HISTIGNORE lets you exclude entire command patterns via glob expressions from history, for example every command starting with mysql -p. This configuration should be set in every shell working on production systems with credentials, ideally centrally via /etc/bash.bashrc or a mandatory profile, rather than relying on each administrator's individual configuration.


# ~/.bashrc or /etc/bash.bashrc — central history hardening

# Ignore commands starting with a space, and immediate duplicates
export HISTCONTROL=ignorespace:ignoredups

# Additionally exclude specific sensitive command patterns
export HISTIGNORE="mysql -p*:*PASSWORD=*:*TOKEN=*:*SECRET=*"

# Write history immediately instead of only on shell exit
export PROMPT_COMMAND="history -a; ${PROMPT_COMMAND:-}"

# Usage: a leading space keeps this command out of history
 mysql -u root -p"$DB_ROOT_PASSWORD" -e "SHOW DATABASES;"

It is important that ignorespace only works if a space actually precedes the command, and that many shells or terminal emulators automatically strip that space on copy paste. Relying exclusively on this feature is therefore risky. The more reliable solution remains never passing secrets as a command line argument in the first place, as shown in the following sections.

3. Keeping secrets out of environment variables and the process list

Even if a secret never ends up in Bash history, it can still become visible through the process list. A command like curl -u user:SecretPassword https://api.example.com shows the password for the duration of execution in the output of ps aux to every user on the same system with read permission on /proc. This applies to every command line argument, regardless of whether it lands in history or not, because ps reads the actual start arguments of the running process.

Environment variables are somewhat better protected, because ps does not show environment variables by default, but under /proc/PID/environ they are still visible to root and the process owner. For maximum safety, secrets should therefore neither exist as a command line argument nor persist as an environment variable, but only reside in the memory of a single process for the minimally necessary time.


#!/usr/bin/env bash
set -euo pipefail

# UNSAFE: password visible in `ps aux` for the entire process runtime
curl -u "user:$DB_PASSWORD" https://api.example.com/status

# BETTER: password passed via stdin, never appears as an argument
curl --user-agent "deploy-script" \
     --data-urlencode "password@-" \
     https://api.example.com/login <<< "$DB_PASSWORD"

# BEST for many CLIs: dedicated --password-stdin style flags
echo "$REGISTRY_TOKEN" | docker login registry.example.com \
     --username deploy --password-stdin

4. Safe handoff of credentials: stdin, files with 600, named pipes

The safest way to pass a secret to a command is via standard input, because stdin is visible neither in the process list nor in Bash history. Many modern CLI tools offer explicit flags for this, such as Docker's --password-stdin or -p- style variants in other tools. Where a tool does not support this, a temporary file with permission 600, deleted immediately after use, is the second best option.

Named pipes, created with mkfifo, offer a third alternative for cases where a secret must pass between two processes without ever touching disk. A secret flowing through a named pipe exists only in a kernel buffer and disappears as soon as both ends of the pipe are closed, leaving no trace on the file system.


#!/usr/bin/env bash
set -euo pipefail

# SAFE: temp file with restrictive permissions, cleaned up via trap
secret_file=$(mktemp)
chmod 600 "$secret_file"
trap 'rm -f "$secret_file"' EXIT

echo "$API_TOKEN" > "$secret_file"
some-cli --token-file "$secret_file" deploy

# SAFE: named pipe, secret never touches disk
pipe=$(mktemp -u)
mkfifo -m 600 "$pipe"
trap 'rm -f "$pipe"' EXIT

( echo "$API_TOKEN" > "$pipe" & )
some-cli --token-file "$pipe" deploy

5. Hardening history files: location, permissions, deletion

The file ~/.bash_history should always have permission 600, so only the owner can read it. On shared systems or bastion hosts, it is additionally worth deliberately keeping HISTSIZE and HISTFILESIZE small, so old entries, including accidentally stored secrets, disappear from the file faster. For highly sensitive sessions, such as on a bastion host with direct access to production systems, unset HISTFILE at the start of the session is a robust option to fully disable history for that session.

It is important that a simple history -c, which clears history in memory, does not automatically clean up the file already written to disk. Only history -c combined with history -w, which explicitly writes the cleared history back, permanently removes the entries from the file as well. Without this second step, any previously stored secret remains present on disk.


# Ensure restrictive permissions on the history file
chmod 600 ~/.bash_history

# Keep history size limited on shared / bastion systems
export HISTSIZE=500
export HISTFILESIZE=500

# Fully disable history for one highly sensitive session
unset HISTFILE

# Clear history in memory AND on disk (both steps required)
history -c
history -w

6. Avoiding secrets in logs and debug output

set -x is a valuable debugging tool, but it prints every executed command with all expanded variable values, including secrets. A script that has set -x active in production, whose output is written to a log file or a central logging system, can thereby leave secrets permanently and searchably in logs. Debugging with set -x should therefore only be used locally and temporarily, never permanently in scripts running in production with real credentials.

For cases where selective debugging in production is unavoidable, the output of set -x should be redirected via BASH_XTRACEFD to a separate descriptor that does not flow into the regular logging system, and secrets should be handled through variable names deliberately excluded from the trace, for example by temporarily disabling set -x immediately before and re enabling it immediately after using a secret.

7. CI/CD variables and secrets managers instead of plaintext in scripts

Bash scripts running in CI/CD pipelines should never store secrets in plaintext in the script or repository, but instead obtain them through the respective CI system's secrets management, such as masked variables in GitLab CI or secrets in GitHub Actions. These systems automatically mask secrets in build output, so they do not accidentally appear in logs, but only if the secret appears as an exact string in the output. If the secret is transformed before output, for example base64 encoded, masking no longer applies.

For production environments with many secrets, a dedicated secrets manager such as Vault, AWS Secrets Manager, or a comparable system is the more robust solution. A Bash script fetches the needed secret at runtime, holds it in memory only for the duration of use, and never writes it to a configuration file or an environment file that could accidentally end up in the repository.

8. Auditing: searching existing history and logs for secrets

After introducing new security measures, it often remains unclear whether secrets are already stored in existing history files or logs. A systematic grep for typical patterns such as -p followed by a non whitespace character, PASSWORD=, TOKEN=, or API_KEY= across all history files and log directories uncovers a large portion of already stored secrets. Specialized tools such as gitleaks or trufflehog extend this principle to Git repositories and additionally recognize known secret formats such as AWS access keys or JWT tokens by their characteristic structure.

Every secret found should not only be removed from history or logs, but treated as compromised and rotated, because it cannot be determined with certainty who already had access to that file in the past. A regular, automated audit run that detects new secrets in logs and histories is the more sustainable solution compared to a one time manual cleanup.


#!/usr/bin/env bash
set -euo pipefail

# Simple audit: search history and logs for common secret patterns
declare -a PATTERNS=(
  '-p[^[:space:]]'
  'PASSWORD='
  'TOKEN='
  'API_KEY='
  'SECRET='
)

for pattern in "${PATTERNS[@]}"; do
  grep -rInE "$pattern" \
    ~/.bash_history /var/log/deploy 2>/dev/null \
    | sed 's/^/[FOUND] /' || true
done

9. Comparison: unsafe vs. safe handling of secrets

The following table summarizes the most common unsafe patterns for handling secrets in Bash against their safe alternatives.

Situation Unsafe Safe Benefit
Database login mysql -u root -pSecret MYSQL_PWD via file with 600, not as an argument No plaintext in history or ps aux
Docker registry login docker login -u u -p Secret --password-stdin Password never visible as an argument
Debugging in production set -x permanently active Temporary, local, isolated with BASH_XTRACEFD Secrets do not end up in logs
Secrets in CI Plaintext in repository or script Secrets manager or masked CI variables Central rotation, no repository leak
Cleaning history history -c alone history -c && history -w File on disk is actually cleaned

Mironsoft

Secrets audits and hardening of Bash automation

Secrets accidentally in history, logs, or the process list?

We systematically search existing history files and logs for stored credentials and set up safe handoff paths for secrets in your scripts and pipelines.

Secrets audit

Systematically search history, logs, and repositories

Safe handoff

Introduce stdin, temporary files with 600, and named pipes

Secrets manager

Integrate Vault or a cloud secrets manager into CI/CD

10. Summary

Secrets almost always end up in Bash history the same way: as a visible command line argument that the shell automatically logs. HISTCONTROL with ignorespace and HISTIGNORE reduce the risk but do not replace a structural solution, because copy paste often strips the protective leading space. The most reliable hardening is to always pass secrets via stdin, temporary files with permission 600, or named pipes, instead of exposing them as an argument or a persistent environment variable.

The process list, debug output via set -x, and CI logs are also common, often overlooked sources of unintentionally stored secrets. A secrets manager instead of plaintext in the script, combined with regular auditing of existing history files and logs, closes the gap between one time hardening and permanent safety in handling credentials in Bash automation.

Avoiding Secrets in Bash History — The Key Points at a Glance

HISTCONTROL

ignorespace plus HISTIGNORE reduce risk but do not replace structural hardening.

Safe handoff

stdin, temporary files with 600, or named pipes instead of secrets as a command line argument.

Process list & logs

ps aux shows arguments to every user. Never run set -x permanently with real secrets in production.

Auditing

Regular grep for typical secret patterns in history and logs, rotate every finding.

11. FAQ: Never Leaving Secrets in Bash History

1Why do secrets end up in Bash history?
Bash stores every interactive command with its arguments. A password as an argument stays stored permanently.
2What does HISTCONTROL=ignorespace do?
Commands with a leading space stay out of history, but copy paste often strips that space.
3Why is a password argument risky without history?
ps aux shows start arguments to everyone with read access on /proc, regardless of history.
4Safest method for secret handoff?
Standard input, invisible in both ps aux and history. Many CLIs offer --password-stdin.
5Is history -c enough alone?
No, only history -w also permanently writes the cleared history back to disk.
6Why is set -x with secrets risky?
Prints every command with expanded values. In logs, secrets end up stored permanently and searchably.
7Environment variables vs. arguments?
Environment variables not in ps aux, but visible under /proc/PID/environ to root and owner.
8How to manage secrets in CI/CD?
Via native CI secrets management or a secrets manager like Vault, never in plaintext in the repository.
9How to find already stored secrets?
Grep for PASSWORD=, TOKEN=, and similar patterns, extended with tools like gitleaks or trufflehog.
10What to do when a secret is found?
Treat it as compromised and rotate immediately, deleting from history alone is not enough.