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

> A practical terminal hardening guide to prevent API key leaks in shell history, env vars, and dotfiles. Includes bash/zsh/fish configs, direnv/mise patterns, secret scanning, and a leak response runbook.

- Canonical: https://www.kunalganglani.com/blog/prevent-api-key-leaks-shell-history
- Author: Kunal Ganglani
- Published: 2026-09-06 · Updated: 2026-09-06
- Category: Developer Tools · Tags: secrets, cli, zsh, bash, direnv

## TL;DR

Pasting API keys into your terminal is the new normal, especially with copy‑paste docs and AI tools. The problem is your shell often saves what you type, and those secrets can end up in history files, dotfiles, screenshots, and even git. This guide shows how to prevent api key leaks in shell history for bash, zsh, and fish, plus safer ways to pass keys to CLI tools without leaving them lying around. You’ll also set up per-project secret loading with direnv or mise, add automated secret scanning, and follow a simple “what to do next” plan if a key ever leaks.

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.

![Computer code on a dark screen with line numbers](https://cdn.sanity.io/images/vzekdneq/production/3d9be4759a478890c94cd46772ceb57e14bf6f33-1200x675.webp)

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.
1. Stop using global `export` as your default. Scope secrets to the smallest possible lifetime.
1. Keep secrets out of dotfiles and repos. Use local-only files.
1. Use `direnv` and `mise` to load per-project secrets safely.
1. Add secret scanning so you get yelled at before GitHub does.
1. 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.

![Three old crt monitors display a line](https://cdn.sanity.io/images/vzekdneq/production/082bfff57f8d9520b382ce9b7c8255a870f1dc41-1200x675.webp)

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](https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning)).

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:

![a computer screen with a lot of data on it](https://cdn.sanity.io/images/vzekdneq/production/7db30eded97d2d521f35f79223e1ad3bbfa3526a-1200x675.webp)

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

```bash
# --- 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:

- `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.

```bash
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`:

```zsh
# --- 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_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 `setopt`s. 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:

```zsh
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:
```bash
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:

```bash
export OPENAI_API_KEY=...    # persists in your shell session
my-cli do-stuff
```

Do:

```bash
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:

```bash
 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:

```bash
# 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:

```bash
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.env` or `~/.secrets/<project>.env`
- source them from your shell config, but never check them into git
Example pattern:

```bash
# ~/.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:

```gitignore
# 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](/blog/ai-security-complete-guide) because these “small” leaks compound when you start using [AI agents](/pillars/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](https://direnv.net/)). It works with bash, zsh, and fish.

The pattern I recommend:

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

```bash
# .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):

```bash
# .env.local (gitignored)
OPENAI_API_KEY=...
STRIPE_SECRET_KEY=...
```

Authorize once:

```bash
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](/blog/redact-secrets-ai-cli), 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](https://mise.jdx.dev/)).

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:

```toml
# 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](/blog/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
1. **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](https://github.com/gitleaks/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](/blog/gitleaks-pre-commit-ci-setup). 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](/pillars/ai-engineering-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:

[Watch: Introduction to secret leaks and getting started with GitHub Secret Protection](https://www.youtube.com/watch?v=vMhDkt5JNN0)

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](https://github.com/newren/git-filter-repo)).

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](/blog/advanced-git-commands-aliases-rewrite) and a migration guide for [jj version control](/blog/jj-version-control-git-compatible) 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.
1. Store secrets in `.env.local` (gitignored).
1. For one-off commands, use ` VAR=... cmd` with a leading space.
1. 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.

Photo by Cong Long Vu on Unsplash.
