DEV Community

Kunal
Kunal

Posted on Originally published at kunalganglani.com

Prevent API Key Leaks in Shell History (bash/zsh/fish) [2026]

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:

  1. Configure bash/zsh/fish to not record the most common secret-bearing commands.
  2. Stop using global export as your default. Scope secrets to the smallest possible lifetime.
  3. Keep secrets out of dotfiles and repos. Use local-only files.
  4. Use direnv and mise to load per-project secrets safely.
  5. Add secret scanning so you get yelled at before GitHub does.
  6. 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:

  1. Use “ignore space” rules (bash/zsh). Prefix sensitive commands with a space.
  2. Use shell-specific “don’t log this” options (zsh has more knobs).
  3. 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
Enter fullscreen mode Exit fullscreen mode

What this does:

  • ignoreboth means any command starting with a space is ignored, and duplicates are ignored.
  • HISTIGNORE is pattern-based filtering. It’s not perfect, but it catches the “export KEY=…” and “curl -H Authorization: …” stuff.
  • HISTSIZE and HISTFILESIZE are 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Two opinions here:

  • Do not enable SHARE_HISTORY unless 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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Do:

OPENAI_API_KEY=... my-cli do-stuff
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.env or ~/.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
Enter fullscreen mode Exit fullscreen mode

And in your dotfiles repo, be aggressive:

# Local secrets
**/*.env
**/*.env.*
.envrc
.env.local
*.pem
*.key
*.p12
*.pfx
Enter fullscreen mode Exit fullscreen mode

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:

  1. Add .envrc to your repo (but keep it non-secret)
  2. Store real secrets in .env.local (gitignored)
  3. Have .envrc load .env.local via dotenv

Example:

# .envrc (checked in)
dotenv_if_exists .env.local

# Optional: enforce a minimum set of vars
: "${OPENAI_API_KEY:?OPENAI_API_KEY missing}"
Enter fullscreen mode Exit fullscreen mode

Then create .env.local (never commit it):

# .env.local (gitignored)
OPENAI_API_KEY=...
STRIPE_SECRET_KEY=...
Enter fullscreen mode Exit fullscreen mode

Authorize once:

direnv allow
Enter fullscreen mode Exit fullscreen mode

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.toml with 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"
Enter fullscreen mode Exit fullscreen mode

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:

  1. Local: stop leaks before they hit git
  2. 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.

  • gitleaks is a popular open-source secret scanner (gitleaks).
  • trufflehog is 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/trufflehog in CI
  • add a pre-commit hook
  • add .env.local and .envrc patterns to .gitignore
  • document the direnv/mise workflow 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:

  1. Use direnv for per-project env loading.
  2. Store secrets in .env.local (gitignored).
  3. For one-off commands, use VAR=... cmd with a leading space.
  4. 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)