How to keep secrets out of shell history on Mac (zsh, bash, fish)
Every interactive shell on your Mac appends what you type to a plain-text file: ~/.zsh_history, ~/.bash_history or ~/.local/share/fish/fish_history. The moment you run export GITHUB_TOKEN=ghp_… or paste a curl -H "Authorization: Bearer sk-…", the full secret is on disk, included in Time Machine, in any dotfile sync, and one up-arrow away for anyone at your keyboard. The removal guides for zsh, bash and fish cover the cleanup. This one is about not needing them.
Two layers. First, tell the shell which lines not to save. Second, stop putting the secret on the command line, because a line that was never typed cannot leak through history, screen recordings or a shoulder surfer.
1. zsh: HIST_IGNORE_SPACE and HISTORY_IGNORE
Many people assume a leading space keeps a command out of zsh history. On a stock Mac it does not. Apple's /etc/zshrc sets only HISTFILE, HISTSIZE and SAVEHIST; we tested zsh 5.9 on macOS 26 with no .zshrc, and echo spaced was saved like any other line. Oh My Zsh sets hist_ignore_space in its lib/history.zsh, which is where the folk memory comes from. Check your own shell:
setopt | grep histignorespace
No output means the option is off. Turn it on in ~/.zshrc:
setopt HIST_IGNORE_SPACE
From then on any line that starts with a space is dropped. The man page adds a detail worth knowing: the command "lingers in the internal history until the next command is entered", so it is still on up-arrow for one step. Press space and Return to make it vanish immediately.
A leading space depends on remembering to type it. HISTORY_IGNORE is a pattern checked when the history file is written, so it catches the lines you forgot about. This drops any export whose name contains TOKEN, KEY or SECRET, plus anything with an AWS access key ID in it:
HISTORY_IGNORE='(export *TOKEN*|export *KEY*|export *SECRET*|*AKIA[0-9A-Z]*)'
The pattern must match the whole line. In our test, with both settings in a throwaway .zshrc, export MY_TOKEN=abc and echo AKIAEXAMPLE never reached the history file while an ordinary echo did. Open a new terminal window or run source ~/.zshrc for the change to apply.
2. bash: HISTCONTROL and HISTIGNORE
macOS still ships bash 3.2.57 at /bin/bash, and both variables work there (we tested on that exact binary). Terminal and iTerm start bash as a login shell, so put these in ~/.bash_profile, or in ~/.bashrc if your profile sources it:
HISTCONTROL=ignorespace
HISTIGNORE='export *TOKEN*:export *KEY*:export *SECRET*:*AKIA[0-9A-Z]*'
ignorespace skips lines that begin with a space. ignoreboth adds ignoredups if you want that too. HISTIGNORE is a colon-separated list of patterns; each one is anchored at the start of the line and must match the complete line, so *AKIA[0-9A-Z]* needs the leading *. Confirm what the running shell has:
echo "$HISTCONTROL" ; echo "$HISTIGNORE"
3. fish: the leading space is built in
fish does this by default, with no setting. The manual: "Prefixing the commandline with a space will prevent the entire line from being stored in the history. It will still be available for recall until the next command is executed, but will not be stored on disk." We confirmed it on fish 4.0.2. There is no pattern-based equivalent of HISTIGNORE, so for a session where you will handle several secrets, start a private shell instead:
fish --private
Private mode "will not access old or store new history" (fish -P is the short form). Everything you type in that window is forgotten when it closes.
4. Stop typing the secret into the command line at all
The settings above hide the symptom. The cause is export TOKEN=the-actual-value: the value goes through your terminal's scrollback, any screen share, and the history mechanism you just configured. Three ways to get the variable set without the value ever being a command-line argument.
Prompt for it with read -s
Every shell can read a line from the terminal without echoing it. Only the read command lands in history; the value you type does not. We verified this in all three shells by checking the history file afterwards.
# zsh
read -s 'GITHUB_TOKEN?Token: ' && export GITHUB_TOKEN
# bash
read -s -p 'Token: ' GITHUB_TOKEN && export GITHUB_TOKEN
# fish (masks what you type with asterisks)
read -s -g -x -P 'Token: ' GITHUB_TOKEN
Paste the value at the prompt and press Return. The variable exists for that window only, which is what you want for a one-off.
Pull it from a password manager or the Keychain
With the 1Password CLI, op read prints a single secret from a reference like op://vault/item/field, and op run starts a program with an .env file whose values are references rather than real secrets:
export GITHUB_TOKEN=$(op read 'op://Dev/GitHub/token')
op run --env-file=./app.env -- npm start
The command line now contains the reference, not the token, so it is fine in history. The macOS Keychain works the same way with nothing to install. Put -w last and security prompts for the password instead of taking it as an argument:
security add-generic-password -a "$USER" -s github-token -w
export GITHUB_TOKEN=$(security find-generic-password -s github-token -w)
Scope it to a directory with direnv
direnv loads variables when you cd into a project and unloads them when you leave. Hook it in (eval "$(direnv hook zsh)" in ~/.zshrc, eval "$(direnv hook bash)" for bash, direnv hook fish | source in config.fish), then put dotenv in the project's .envrc so it reads the .env next to it, and run direnv allow once. The secret still sits in a file, so .env and .envrc must be gitignored; see how to prevent committing .env files to git. Better still, make the .env hold op:// references and let op run resolve them.
5. Check what is already there
None of this removes what was saved before today. Search by variable name or key prefix rather than the full value, and start the line with a space now that the shell honours it:
grep -n -E 'AKIA|ghp_|sk-|xox[bp]-|TOKEN=|SECRET=' ~/.zsh_history ~/.bash_history ~/.local/share/fish/fish_history 2>/dev/null
Anything that turns up is already on disk; rotate it if it was real, then remove the line with the guide for your shell. If it also went into a git commit, see how to remove an API key from git history.