Originally published at kunalganglani.com — read it there for inline code, hero image, and live links.
You can lock down 80% of “oops I leaked a key” incidents in about 20 minutes. The prerequisite that trips people up is this: your shell will happily persist whatever you type (history files, dotfiles, exports), and modern AI/agentic tooling has made us paste secrets into terminals more often than ever.
This guide is specifically about how to prevent API key leaks in shell history across bash, zsh, and fish. I’ll give you copy-pasteable hardening snippets, the safer way to pass secrets to CLIs locally, how to use direnv and mise without committing keys, and what to do when a key still leaks.
Based on the keyword neighborhood signals I pulled from my own site’s research tooling, this is a winnable problem space for teams: 99 related impressions in the “prevent secret leaks” neighborhood, with a best observed position ~2.7, across an estimated ~460 searches/month worth of adjacent queries. In other words: people are searching for this. And they’re mostly getting bad advice.
What is terminal secrets hygiene?
Terminal secrets hygiene is the set of practices that prevents secrets you type or load in a terminal (API keys, tokens, passwords) from being permanently stored in shell history, environment exports, dotfiles, logs, screenshots, or git.
The reason I’m opinionated about this: the “just don’t do that” approach fails the minute you’re debugging a production issue at 2 a.m. or copy/pasting a vendor’s curl example into a terminal. You need guardrails that work when you’re tired.
Here’s the checklist we’re going to implement:
- Configure bash/zsh/fish to not record the most common secret-bearing commands.
- Stop using global
exportas your default. Scope secrets to the smallest possible lifetime. - Keep secrets out of dotfiles and repos. Use local-only files.
- Use
direnvandmiseto load per-project secrets safely. - Add secret scanning so you get yelled at before GitHub does.
- Have a runbook for when a key leaks anyway.
Shell history basics: where history is stored and why secrets leak
Before settings, you need the mental model.
Most shells append your commands to a plain-text history file in your home directory. On macOS and Linux, it’s typically one of:
- bash:
~/.bash_history - zsh:
~/.zsh_history - fish:
~/.local/share/fish/fish_history
That history file is:
- readable by you (obviously)
- often backed up (Time Machine, corporate laptop backup agents)
- often copied around when people sync dotfiles across machines
- sometimes exfiltrated by infostealers that target developer machines
The most common leak patterns I see in real teams:
- A vendor doc says:
curl -H "Authorization: Bearer sk_live_..." ... - Someone types:
export AWS_SECRET_ACCESS_KEY=... - Someone puts
export OPENAI_API_KEY=...into~/.zshrc“temporarily” and forgets - Someone shares a terminal screenshot to Slack with the token visible
- An agentic CLI tool logs the full command line, then you paste that log into a ticket
GitHub has an entire product area dedicated to stopping this class of mistake, because it happens constantly: Secret scanning / GitHub Secret Protection is designed to detect secrets committed to repos and help you remediate them (GitHub).
Also, AI tooling makes this worse. As Dwayne McDaniel of GitGuardian puts it in their write-up on agentic workflows, when tools can act across systems, “the more systems an agent can reach, the more consequential a failure in its credential and execution layer becomes” (Dwayne McDaniel).
So yes, history settings matter. But they’re not the whole story.
How do I stop a command from being saved in shell history (bash/zsh/fish)?
You’ve got three practical moves, in order of usefulness:
- Use “ignore space” rules (bash/zsh). Prefix sensitive commands with a space.
- Use shell-specific “don’t log this” options (zsh has more knobs).
- Use a private/no-history session when you’re about to do sketchy things (fish has an explicit mode).
Here’s the cross-shell cheat sheet.
| Shell | Fastest “don’t save this” option | Best persistent hardening | Where to put it |
|---|---|---|---|
| bash | Prefix command with a leading space (with HISTCONTROL=ignorespace) |
HISTCONTROL=ignoreboth + HISTIGNORE patterns |
~/.bashrc / ~/.bash_profile
|
| zsh | Prefix command with a leading space (with setopt HIST_IGNORE_SPACE) |
setopt HIST_IGNORE_SPACE + HIST_SAVE_NO_DUPS + sane file perms |
~/.zshrc |
| fish | Start fish --private
|
private sessions for sensitive work; avoid storing secrets in vars |
config.fish (settings) |
If you only do one thing today, do the leading-space rule. It’s low friction and catches the “copy/paste a curl with a token” habit.
Bash: history controls (HISTCONTROL/HISTIGNORE) and safe patterns
Bash is blunt but effective.
Add this to ~/.bashrc (or wherever your bash config lives):
# --- secrets hygiene: bash history ---
# ignoreboth = ignorespace + ignoredups
export HISTCONTROL=ignoreboth
# Keep history smaller. Big history = bigger blast radius.
export HISTSIZE=5000
export HISTFILESIZE=10000
# Don't record obvious secret-bearing commands. Tune for your stack.
export HISTIGNORE='*--password*:*--token*:*--secret*:*Authorization:*:export *KEY=*:*AWS_SECRET_ACCESS_KEY*:*OPENAI_API_KEY*:*GITHUB_TOKEN*'
# Ensure history is appended (not overwritten) in multi-shell usage
shopt -s histappend
What this does:
-
ignorebothmeans any command starting with a space is ignored, and duplicates are ignored. -
HISTIGNOREis pattern-based filtering. It’s not perfect, but it catches the “export KEY=…” and “curl -H Authorization: …” stuff. -
HISTSIZEandHISTFILESIZEare boring but important. If your history file is 2 MB instead of 200 MB, you have less to scrub when something goes wrong.
One more thing people miss: permissions.
chmod 600 ~/.bash_history 2>/dev/null || true
If you’re on a shared box, “world-readable history files” is an embarrassment you don’t want.
Zsh: history controls (setopt options) and safe patterns
Zsh has more knobs, and you should use them.
Add this to ~/.zshrc:
# --- secrets hygiene: zsh history ---
# Ignore commands that start with a space
setopt HIST_IGNORE_SPACE
# Reduce duplicate noise and accidental repeats
setopt HIST_IGNORE_ALL_DUPS
setopt HIST_SAVE_NO_DUPS
# Write history incrementally so you don't lose it. (Not a security feature, just sanity.)
setopt INC_APPEND_HISTORY
# Keep history size reasonable
HISTSIZE=5000
SAVEHIST=10000
# Lock down history file permissions
HISTFILE=${HISTFILE:-$HOME/.zsh_history}
chmod 600 "$HISTFILE" 2>/dev/null || true
Two opinions here:
-
Do not enable
SHARE_HISTORYunless you understand the tradeoff. Shared history across sessions is convenient, but it expands how quickly a bad paste propagates. - A lot of “zsh security snippets” on the internet are cargo cult. Your goal is not to collect
setopts. Your goal is to make the risky path slightly annoying.
If you want one extra guardrail, add a “are you sure?” alias for the worst offenders:
alias export='echo "Don't export secrets globally. Use per-command env vars."; export'
It’s not bulletproof. It’s a speed bump.
Fish: private mode/history settings and safe patterns
Fish does you a favour here: it has a clear private mode.
- Start a private session:
fish --private
In private mode, fish won’t write new history to disk. That’s exactly what you want when you’re about to test credentials, debug auth, or do anything you don’t want hanging around.
In non-private fish sessions, the “safe pattern” is less about history and more about not passing secrets on the command line in the first place. Which brings us to the thing most posts ignore.
Environment variable scoping: avoid exporting globally; use per-command/env wrappers
“Just put it in an environment variable” is one of the most misleading pieces of security advice in dev tooling.
Does putting a secret in an environment variable keep it safe? No. It changes the exposure surface.
Here’s what environment variables do (and why they bite you):
- They are inherited by child processes by default.
- They’re often visible to the same user via process inspection tooling.
- They get copied into crash reports and debugging output more often than you’d like.
- They get written into CI logs if you echo them, print env dumps, or run tools with
--verbose.
So what’s better?
1) Prefer per-command environment variables
Instead of:
export OPENAI_API_KEY=... # persists in your shell session
my-cli do-stuff
Do:
OPENAI_API_KEY=... my-cli do-stuff
That scopes the secret to that process and its children. It’s still not “secure”, but it’s shorter-lived and less likely to leak via “I forgot I exported that 4 hours ago.”
This is also the habit that pairs well with the leading-space history rule:
OPENAI_API_KEY=... my-cli do-stuff
# ^ leading space keeps it out of history (bash/zsh with the right settings)
2) Prefer stdin or files over argv
If a tool supports reading a token from stdin or a file, take it. Command-line arguments are the worst place for secrets because they get logged, copied, and pasted.
Concrete example: if you must call curl, don’t inline the token. Put it in a header file or use an env var:
# Better than: curl -H "Authorization: Bearer ..."
TOKEN=... \
curl -H "Authorization: Bearer $TOKEN" https://api.example.com/me
Still not perfect, but it avoids the “token is literally in the command string” scenario that gets screenshotted.
3) Reduce lifetime: unset aggressively
If you did export something, clean up:
unset OPENAI_API_KEY AWS_SECRET_ACCESS_KEY GITHUB_TOKEN
Make this muscle memory. The difference between “secret existed for 30 seconds” and “secret existed for 3 days” is huge when you’re running random CLIs.
Dotfiles hygiene: avoid committing secrets; .gitignore patterns; separate local/private files
Dotfiles are where secrets go to die.
The “I’ll just add it to my ~/.zshrc temporarily” move is how keys end up:
- in a dotfiles repo
- in a gist
- in a coworker’s PR review
- in a laptop migration tarball
My rule: dotfiles should contain loaders, not secrets.
Use patterns like:
- keep local secrets in
~/.config/<tool>/secrets.envor~/.secrets/<project>.env - source them from your shell config, but never check them into git
Example pattern:
# ~/.bashrc or ~/.zshrc
if [ -f "$HOME/.config/secrets/global.env" ]; then
set -a
. "$HOME/.config/secrets/global.env"
set +a
fi
And in your dotfiles repo, be aggressive:
# Local secrets
**/*.env
**/*.env.*
.envrc
.env.local
*.pem
*.key
*.p12
*.pfx
This is also where I’ll plug a workflow stance: if you’re doing a lot of terminal-based AI tooling, treat your terminal like a log sink. I wrote a dedicated guide on AI security because these “small” leaks compound when you start using AI agents that touch multiple systems.
Use env managers: direnv workflow for per-directory secrets; mise patterns for tasks/env
This is the boring answer that is actually the right one: use per-directory environment loading so secrets exist only inside the project context.
direnv: local secrets that load on cd and unload on exit
direnv is an extension for your shell that can load and unload environment variables based on the current directory (direnv). It works with bash, zsh, and fish.
The pattern I recommend:
- Add
.envrcto your repo (but keep it non-secret) - Store real secrets in
.env.local(gitignored) - Have
.envrcload.env.localviadotenv
Example:
# .envrc (checked in)
dotenv_if_exists .env.local
# Optional: enforce a minimum set of vars
: "${OPENAI_API_KEY:?OPENAI_API_KEY missing}"
Then create .env.local (never commit it):
# .env.local (gitignored)
OPENAI_API_KEY=...
STRIPE_SECRET_KEY=...
Authorize once:
direnv allow
The security feature here is not “dotenv is magic”. The security feature is: when you leave the directory, the variables are unloaded. That reduces accidental reuse.
If you’re building agentic CLIs, pair this with log redaction. I’ve gone deep on that in redacting secrets in an AI coding CLI tool, because agents love printing things you wish they didn’t.
mise: define env and tasks together, but keep secrets out of mise.toml
mise (mise-en-place) is a “tools + env + tasks” manager. It can load per-project environment variables and read from local env files (mise).
The safe pattern is:
- check in
mise.tomlwith non-secret defaults - read secrets from a local file like
.env.local
mise even shows this idea right in its docs examples: it can load env vars “from .env.local” via _.file = ".env.local".
Example:
# mise.toml (checked in)
[env]
# non-secret defaults
API_BASE_URL = "https://api.example.com"
# load secrets from a local file that is gitignored
_.file = ".env.local"
[tasks.dev]
run = "npm run dev"
Then .env.local is the same as above, and you’ve got tasks that run with the right env without teaching every dev to export things manually.
If you want to go further, I’ve already written about using both together in direnv + mise for a reproducible terminal dev environment.
Add secret scanning: pre-commit + repo scanners; GitHub secret scanning/push protection
You need two layers:
- Local: stop leaks before they hit git
-
Remote: stop leaks before they hit
main
Local scanning: gitleaks and trufflehog
I’m not going to pretend regex scanning is perfect. It’s still one of the highest ROI controls you can add in a day.
-
gitleaksis a popular open-source secret scanner (gitleaks). -
trufflehogis another widely used tool that can also verify certain credentials (TruffleHog).
The workflow I like is:
- run
gitleaks(fast) on pre-commit - run
trufflehog(deeper) in CI on PRs
If you want a step-by-step setup, I already published gitleaks + pre-commit + CI. Hooking scanners into your normal dev loop beats “quarterly security reminders” every time.
Also: if you’re adopting agentic tooling or “vibe coding” workflows, do not trust that logs won’t capture secrets. Put this in your AI in production checklist right next to observability and rate limits.
Remote scanning: GitHub Secret Protection and push protection
GitHub’s secret scanning exists because “we’ll catch it in code review” doesn’t work.
Here’s the official intro video from GitHub:
[YOUTUBE:vMhDkt5JNN0|Introduction to secret leaks and getting started with GitHub Secret Protection]
Enable secret scanning for repos where it’s available, and if you can, enable push protection. The goal is simple: make it hard to push a key even if someone tries.
If your team is already living on GitHub, this is a no-brainer control. It’s the closest thing to a seatbelt you can add without changing developer behaviour.
When a key leaks: rotate/revoke, scrub history, scrub git history, and notify/log review
Leaks happen. The only unacceptable move is “hope nobody noticed.”
Here’s the runbook I want you to follow the minute an API key is exposed in a terminal, repo, or log.
Step 1: Revoke or rotate immediately
Do this first. Not after cleanup.
- Rotate the API key at the provider.
- If it’s a cloud key, invalidate sessions/credentials where possible.
- If it’s scoped (good), rotate only that scope. If it’s broad (bad), assume blast radius.
Time matters. If a secret hit a public repo for even 1 minute, you should assume it’s compromised.
Step 2: Figure out where it leaked
You need to know if this is:
- terminal-only (history / screenshot / scrollback)
- git (committed, PR, or pushed)
- CI logs (workflow output)
- issue trackers / chat (Slack, Jira)
Each has different cleanup.
Step 3: Scrub shell history locally
If the leak was “I typed it”, you have to clean:
- your history file (
~/.zsh_history,~/.bash_history, fish history) - your terminal scrollback (some terminals persist it)
Practically: open the history file, remove the line(s), and consider truncating.
Also: if you used export SOME_KEY=..., search your dotfiles for it. People forget they set it in two places.
Step 4: If it hit git, rewrite history (properly)
Removing a secret from the current HEAD is not enough. You must remove it from history.
Use git filter-repo (the modern replacement for filter-branch) (Elijah Newren).
After rewriting:
- force-push the rewritten history
- rotate the secret again (assume it was copied)
- invalidate old clones if you can (hard in practice)
If this feels extreme, good. It should. You’re paying down an incident.
For a safer day-to-day git workflow (which reduces how often you’re doing history surgery), I’ve also got a guide on advanced Git commands safely and a migration guide for jj version control if you’re experimenting.
Step 5: Prevent reintroduction
This is where most teams fail. They clean up and move on.
Do these within 24 hours:
- enable GitHub secret scanning / push protection where possible
- add
gitleaks/trufflehogin CI - add a pre-commit hook
- add
.env.localand.envrcpatterns to.gitignore - document the
direnv/miseworkflow in your repo README
If you’re using agentic CLIs, also harden logging and redaction. This intersects with LLM security and AI security more than most people want to admit.
Step 6: Audit
Even on small teams, do a minimal audit:
- check provider access logs for the time window (last 24 hours at minimum)
- check GitHub audit/security logs if the leak was in a repo
- search Slack/Jira for the token prefix
You’re not hunting for perfection. You’re trying to answer: “Did anyone use this?”
The safest way to provide API keys to CLI tools locally (my default)
If you want my default workflow in 2026:
- Use
direnvfor per-project env loading. - Store secrets in
.env.local(gitignored). - For one-off commands, use
VAR=... cmdwith a leading space. - Run scanners locally and in CI.
If you do that, you’ve made the dangerous path annoying and the safe path easy.
If you’re building internal tooling, here’s my challenge: make “paste the key into the terminal” the worst UX. Accept stdin. Accept files. Integrate with a secrets manager. Your future self will thank you.
My prediction: as agentic CLIs become normal, the next wave of “secret leaks” won’t come from humans fat-fingering curl. It’ll come from tools that log too much. If you’re not treating logs as an attack surface, you’re already behind.
Originally published on kunalganglani.com
Top comments (0)