By Manny Fernandez

November 12, 2026

Clear Your Bash and Zsh History (and Keep API Keys Out of It)

Clear Your Bash and Zsh History (and Keep API Keys Out of It)

No fluff. Just the config that works.

Executive Summary

Every API key you paste into a terminal has a good chance of ending up in your Bash and Zsh history file, in plain text, sitting in your home directory for months. This post shows how to clear Bash and Zsh history the right way (memory and file, not just one of them), how to surgically delete a single leaked line, and how to set up your shell so secrets never get recorded in the first place.

Objective: wipe or edit shell history reliably on Linux and macOS, then run sessions where you enter API keys, tokens and passwords without leaving them in history, scrollback, or the process list.

Target audience: network and security engineers, SEs, and admins who live in a terminal and regularly paste FortiGate API tokens, cloud keys, or vendor portal passwords into it.

Prerequisites and Architecture

Assumed knowledge: basic shell use, editing ~/.bashrc or ~/.zshrc, and knowing which shell you run (echo $SHELL).

Lab used for this post:

Component Version / Value Role
Ubuntu Server 24.04 LTS Bash 5.2 Linux test host
macOS (Apple Silicon) Zsh 5.9, Terminal and iTerm2 Mac test host
FortiGate (lab) 10.0.0.1 on the 10.0.0.0/16 LAN REST API target for the token examples
Secret store macOS Keychain (security), pass, or 1Password CLI (op) Where the API token actually lives

Where shell history actually lives

The key thing most “just delete the file” tips miss: your shell keeps history in two places. There is an in-memory list for the running session, and a history file on disk. The shell loads the file when it starts and writes the in-memory list back when it exits. Delete the file while a session is open, close the window, and the shell happily writes everything back.

Location Bash Zsh
History file ~/.bash_history ($HISTFILE) ~/.zsh_history ($HISTFILE, set in /etc/zshrc on macOS, in your ~/.zshrc on most Linux setups)
When it is written On exit, or every prompt if you use history -a in PROMPT_COMMAND On exit, or every command with INC_APPEND_HISTORY / SHARE_HISTORY
Size controls HISTSIZE (memory), HISTFILESIZE (file) HISTSIZE (memory), SAVEHIST (file)
Extra copies Root’s file if you sudo -i ~/.zsh_sessions/ from macOS Terminal session restore

Figure 1: Where a secret typed into a shell ends up

Diagram showing a typed API key flowing to shell history, scrollback, process arguments, macOS session files and logs

History is only one of the places a secret leaks. The same key also shows up in terminal scrollback, in the process list while a command runs, and in any audit or EDR telemetry that records command lines. Attackers know this: MITRE ATT&CK tracks it as Unsecured Credentials: Shell History (T1552.003), and the Kinsing malware is a documented example of scraping .bash_history for credentials.

Step-by-Step Implementation

Step 1: Check what your shell records today

Goal: know your shell, your history file, and which privacy options are already on.

Action: run the block for your shell.

# Which shell and version?
echo "$SHELL"; echo "bash=$BASH_VERSION zsh=$ZSH_VERSION"

# Bash: file, filters, and append mode
echo "HISTFILE=$HISTFILE HISTCONTROL=$HISTCONTROL HISTIGNORE=$HISTIGNORE"
shopt histappend
# Zsh: file, size, and history options that differ from defaults
echo "HISTFILE=$HISTFILE SAVEHIST=$SAVEHIST"
setopt | grep -Ei 'hist|share'

Zsh prints option names in lowercase without underscores, so look for histignorespace, incappendhistory and sharehistory.

Verification: confirm the history file is readable by you only.

ls -l ~/.bash_history ~/.zsh_history 2>/dev/null
chmod 600 ~/.bash_history ~/.zsh_history 2>/dev/null

You want -rw-------. Anything wider means other local users can read every command you have typed.

Step 2: Clear Bash history completely

Goal: empty both the in-memory list and ~/.bash_history, in the right order.

Action: do not start with rm. Clear memory first, then write the empty list over the file.

history -c    # wipe the in-memory list for this session
history -w    # overwrite $HISTFILE with the (now empty) list

If you only want to make sure this session never touches disk, there are two exits that skip the save:

unset HISTFILE; exit    # nothing to write to, so nothing is written
kill -9 $$              # SIGKILL cannot be trapped, so the save never runs

Verification:

wc -l ~/.bash_history     # expect: 0 /home/<user>/.bash_history
history                   # expect: only the commands typed since the clear

Other open Bash windows still hold their own lists and will write them on exit. Close them first, or run history -c; history -w in each one.

Step 3: Clear Zsh history completely

Goal: the same result in Zsh, which has no history -c (in Zsh, history is just fc -l, and -c returns fc: bad option: -c).

Action: stop the current shell from writing, truncate the file, then replace the shell with a fresh one.

unset HISTFILE          # this shell can no longer write history on exit
: > ~/.zsh_history      # truncate the file (keeps owner and 600 permissions)
exec zsh                # fresh shell with an empty in-memory list

macOS Terminal extra step: Apple’s Terminal keeps per-window session history in ~/.zsh_sessions/ through /etc/zshrc_Apple_Terminal. Clear it and turn it off if you do not use session restore:

rm -rf ~/.zsh_sessions
echo 'export SHELL_SESSIONS_DISABLE=1' >> ~/.zshenv

Verification:

wc -c ~/.zsh_history      # expect: 0 /Users/<user>/.zsh_history
ls ~/.zsh_sessions 2>&1   # expect: No such file or directory

Step 4: Delete one sensitive line instead of everything

Goal: remove a leaked key without losing months of useful history.

Action (Bash): find the entry number, delete it, then sync the file.

history | grep 'sk_live_'       # search by a prefix, never paste the full key
history -d 1042                 # delete entry 1042
history -d 1040-1045            # or delete a range (Bash 5.0 and later)
history -w                      # write the cleaned list to $HISTFILE

If the leak was the command you just ran, history -d -2 deletes the entry right before the history -d itself (negative offsets count back from the end; -1 is the delete command). Use Bash 5.1 or later for this, since 5.0 had a bug where negative offsets could fail with “history position out of range”.

Action (Zsh): there is no per-entry delete, so edit the file with the shell’s hands off it.

grep -n 'sk_live_' ~/.zsh_history                 # confirm what will be removed
unset HISTFILE                                    # keep this shell off the file
LC_ALL=C sed -i '' '/sk_live_/d' ~/.zsh_history   # macOS (BSD sed)
LC_ALL=C sed -i '/sk_live_/d' ~/.zsh_history      # Linux (GNU sed)
exec zsh

Verification: grep -c 'sk_live_' ~/.zsh_history (or ~/.bash_history) returns 0.

Step 5: Stop secrets from being recorded in the first place

Goal: make the shell skip sensitive lines automatically.

Action 5a, the leading space: with this enabled, any command that starts with a space is not saved.

# ~/.bashrc
HISTCONTROL=ignoreboth    # ignorespace + ignoredups
# ~/.zshrc
setopt HIST_IGNORE_SPACE

Then type secrets like this (note the space before export):

 export FGT_TOKEN='<paste-token-here>'

Zsh nuance: the space-prefixed line stays in memory until you run the next command, so the Up arrow can still recall it. Per the Zsh manual, type a single space and press Return to make it vanish right away.

Action 5b, pattern filters: catch the times you forget the space.

# ~/.bashrc  (patterns are colon-separated, so do not put a colon inside one)
HISTIGNORE='*API_KEY*:*TOKEN*:*SECRET*:*PASSWORD*:*Bearer*:*--password*'
# ~/.zshrc  (a non-zero return keeps the line out of history)
zshaddhistory() {
  emulate -L zsh
  [[ $1 != *(API_KEY|TOKEN|SECRET|PASSWORD|Bearer|--password)* ]]
}

Zsh also has HISTORY_IGNORE='(*TOKEN*|*SECRET*)', but it only filters what gets written to the file, so the line still sits in memory for the session. The zshaddhistory hook stops it at the door.

Action 5c, incognito mode for a whole session:

set +o history     # Bash: stop recording
# ... work with secrets ...
set -o history     # resume recording
fc -p              # Zsh: push a private, empty history list (not tied to a file)
# ... work with secrets ...
fc -P              # pop back to your normal history

Verification: run the canary test in the Verification section below.

Step 6: Handle API keys without typing them on the command line

Goal: get the key into the session without it ever appearing in history, scrollback, or ps.

Action 6a, prompt for it silently:

read -rsp 'API token: ' FGT_TOKEN; echo; export FGT_TOKEN       # Bash
read -rs 'FGT_TOKEN?API token: '; echo; export FGT_TOKEN         # Zsh

Nothing is echoed, and the history only shows the read command, not the value. (In Zsh, read -p means “read from a coprocess”, which is why the prompt uses the name?prompt form.)

Action 6b, pull it from a secret store:

# macOS Keychain: store once (prompts for the value because -w is last)
security add-generic-password -a "$USER" -s fgt-lab-api -w

# Load into the session when needed
export FGT_TOKEN="$(security find-generic-password -a "$USER" -s fgt-lab-api -w)"
# Linux with pass, or any OS with the 1Password CLI
export FGT_TOKEN="$(pass show lab/fortigate-api)"
export FGT_TOKEN="$(op read 'op://Lab/FortiGate API/credential')"

Action 6c, keep it out of the process list: curl -H "Authorization: Bearer $FGT_TOKEN" keeps the literal $FGT_TOKEN in history, but the expanded token sits in curl’s arguments where ps and /proc/<pid>/cmdline can see it while the command runs. Feed the header from a file descriptor instead. printf is a shell builtin, so it never shows up as a process:

curl -sk -H @<(printf 'Authorization: Bearer %s\n' "$FGT_TOKEN") \
  https://10.0.0.1/api/v2/monitor/system/status

The same idea applies to other tools: docker login <registry> -u <user> --password-stdin, mysql --defaults-extra-file=<file>, and sshpass -e (reads SSHPASS from the environment) all avoid putting the secret in argv.

Action 6d, use a locked-down env file for repeat work:

chmod 600 lab.env && echo 'lab.env' >> .gitignore
set -a; . ./lab.env; set +a     # export every variable defined in the file

Action 6e, clean up when you are done:

unset FGT_TOKEN API_KEY          # drop the variables
clear && printf '\033[3J'        # clear the screen and the scrollback buffer
tmux clear-history               # if you are inside tmux

In Terminal and iTerm2, Command K also clears the scrollback. Environment variables are a convenience, not a vault: the OWASP Secrets Management Cheat Sheet points out that they are visible to child processes and can land in logs or dumps, so keep them short-lived.

GUI Verification: open Activity Monitor (macOS) or run ps -eo pid,args | grep curl from a second terminal while a long request runs. The token should not appear in the arguments.

Step 7: Hunt down the other history files

Goal: find secrets that leaked into REPL and client histories, not just the shell.

File Written by
~/.python_history Python interactive interpreter
~/.mysql_history MySQL / MariaDB client
~/.psql_history PostgreSQL psql
~/.sqlite_history SQLite shell
~/.node_repl_history Node.js REPL
~/.rediscli_history redis-cli
/root/.bash_history Anything you ran after sudo -i or sudo su
~/.zsh_sessions/ macOS Terminal session restore

Action: sweep them for common key formats (AWS access key IDs, GitHub classic tokens, Stripe live keys, bearer headers).

grep -RIlE -e 'AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36}|sk_live_' \
  -e 'Bearer [A-Za-z0-9._-]{20,}' ~/.*history* ~/.zsh_sessions 2>/dev/null

Any file this prints gets the Step 4 treatment, and any key it finds gets rotated.

Verification and Validation

Run a canary test after changing your config. Open a new terminal so the new settings load.

 echo space-canary-1                 # leading space
echo "MY_TOKEN=pattern-canary-2"     # matches the TOKEN filter
echo normal-line-3                   # should be saved
exit

Open another new terminal and check the file:

grep -c 'canary' ~/.bash_history     # or ~/.zsh_history
grep -c 'normal-line-3' ~/.bash_history

Expected success output:

0
1

The canaries are gone and the normal line was saved, so filtering works without breaking history. In Zsh you can also run fc -l -5 in the first session after typing a single space and Return; neither canary should be listed.

Troubleshooting and Gotchas

1. “I deleted the file and the history came back”

Cause: another open shell wrote its in-memory list on exit, or SHARE_HISTORY / INC_APPEND_HISTORY re-wrote it from a session that was still running.

Diagnose:

ps -o pid,tty,comm -u "$USER" | grep -E 'bash|zsh'

Fix: close the other shells first, or run unset HISTFILE in each before clearing. Then use Step 2 or Step 3.

2. “The leading space does nothing”

Cause: HISTCONTROL is not set (common on macOS Bash and some RHEL builds; Ubuntu and Debian ship ignoreboth in the default ~/.bashrc), or HIST_IGNORE_SPACE is off in Zsh. In Zsh, the Up arrow recalling the line until your next command is by design, not a failure.

Diagnose:

echo "$HISTCONTROL"                      # Bash: expect ignorespace or ignoreboth
setopt | grep histignorespace            # Zsh: expect histignorespace

Fix: add the Step 5a lines to the right rc file and open a new terminal.

3. “sed: RE error: illegal byte sequence” on macOS

Cause: Zsh stores non-ASCII characters in a “metafied” byte form, and BSD sed refuses bytes that are not valid in your UTF-8 locale.

Fix: prefix the command with LC_ALL=C, as in Step 4, so sed treats the file as raw bytes.

4. The history is clean, but the key is still out there

Clearing history does not reach Time Machine or other backups, terminal session logs, auditd or EDR command-line telemetry, or the server logs on the API side. Clearing history is also something defenders watch for: MITRE ships a detection strategy for unexpected reads of `.bash_history`, and a wiped history on a server is a classic indicator-removal signal that can trigger an investigation.

Fix: if a real key ever hit history, rotate it. Clearing history is hygiene; rotation is remediation.

Bash and Zsh History Quick Reference

Task Bash Zsh
Clear everything history -c; history -w unset HISTFILE; : > ~/.zsh_history; exec zsh
Delete one entry history -d <n>; history -w LC_ALL=C sed -i '' '/<prefix>/d' ~/.zsh_history
Skip one command Leading space + HISTCONTROL=ignoreboth Leading space + setopt HIST_IGNORE_SPACE
Skip by pattern HISTIGNORE='*TOKEN*:*SECRET*' zshaddhistory hook
Private session set +o history / set -o history fc -p / fc -P
Don’t save this session unset HISTFILE unset HISTFILE
Prompt for a secret read -rsp 'Key: ' KEY read -rs 'KEY?Key: '

Sources

GitLab CVE-2026-97229: Patch, Verify and Hunt the CI Regex RCE

GitLab CVE-2026-97229: Patch, Verify and Hunt the CI Regex RCE

GitLab CVE-2026-97229 is a CVSS 10 RCE now in CISA KEV. Find the build that really fixes it, apply stopgaps, and hunt for exploitation.

Read More
EJBCA Internal CA: Deploy a Private PKI for Your Local Servers with Docker

EJBCA Internal CA: Deploy a Private PKI for Your Local Servers with Docker

Deploy an EJBCA internal CA in Docker with MariaDB, build a two-tier Root and Issuing CA, and issue trusted TLS certificates to local servers.

Read More
CVE-2026-98432 Mitigation on FortiGate: Blocking the Linux Kernel TCP Use-After-Free (FG-IR-26-188)

CVE-2026-98432 Mitigation on FortiGate: Blocking the Linux Kernel TCP Use-After-Free (FG-IR-26-188)

CVE-2026-98432 mitigation for FortiGate and Linux: drop IPv4 options in the NPU, apply the FMWP virtual patch, and block the TCP trigger until you...

Read More