Claude Code Accidentally Committed a Secret

Claude Code swept your `.env` or API key into a commit. Rotate the key first (always), purge git history second, then block it for good with a deny rule, scanner, and push protection.

Claude Code ran git add . && git commit -m "..." and the diff includes .env.local with your live Stripe key. Or it added a test fixture holding a real OpenAI API key left over from your last debugging session. The commit is local — but if it was pushed, that secret is now in remote git history, GitHub’s search index, and any fork that pulled before you noticed.

Fastest fix (do this now): rotate the leaked key at the provider, then purge it from history. Rotation is the only step that actually closes the exposure; everything else just reduces who can find it. Order matters — rotate first, rewrite history second, never the other way around.

Then stop the next one with three backstops that don’t rely on the agent behaving: a permissions.deny rule in settings.json that hard-blocks git add . and git add -A, a pre-commit secret scanner, and GitHub push protection. Agents can’t reliably tell “secret” from “test data,” so the fix is to make a broad add impossible rather than ask the model to be careful.

Which bucket are you in?

SituationExposureFirst move
Committed locally, not pushedDisk onlyRotate if the key ever touched a backed-up/indexed file, then git reset (Step 2)
Pushed to a private repoRemote history + collaboratorsRotate now, then git filter-repo + force-push (Step 2)
Pushed to a public repoPublic the instant it landedRotate now; assume bots already scraped it. History rewrite is cleanup, not containment
In a commit message or branch nameGit metadata, not the diffRotate; the string is in reflog/refs, so history rewrite is still needed

If you are unsure whether the commit was pushed, run git log origin/main..HEAD — if your secret-bearing commit appears there, it is local-only; if it does not, it has already reached the remote.

Common causes

Ordered by hit rate, highest first.

1. Agent used git add . or git add -A

The agent finished editing and ran the catch-all add. .env.local, .env.production, secrets.json, an SSH key — all swept in if they weren’t in .gitignore. This is by far the most common path: the model wants to stage “its work” and reaches for the broadest command.

How to spot it: Run git show --stat HEAD (or check the commit’s file list in your client). If any file outside the task scope is included, the staging was broad.

2. .gitignore doesn’t cover the secret path

You have .env ignored but your project also reads config/secrets.json or .env.development.local. Either the gitignore is stale or never covered this path.

How to spot it: git check-ignore -v <secret-path> returns nothing — meaning the file is not ignored.

3. Secret hardcoded in test fixture / docs

A .test.ts file contains a real key for debugging “just for a minute.” Agent picked it up as legitimate test data and committed.

How to spot it: Secret appears in a file that has nothing to do with config — a test, a doc, a script. Provenance is “left over from debugging.”

4. New env variable in .env but .env.example got the real value

You added STRIPE_LIVE_KEY= to .env.example to document the new var, but accidentally typed the real key instead of <your-key-here>. Agent committed .env.example (not gitignored).

How to spot it: .env.example has real-looking values, not placeholders.

5. Token in commit message or branch name

Branch named fix/sk-test-abc123-payment-bug (literal key). Or commit message includes // debug: sk_live_xxx. Not “in” the diff but still leaked into git metadata.

How to spot it: Search history for sk_, ghp_, xoxb_, or whatever prefix your platform uses, including in commit messages.

6. Generated artifact contains an embedded secret

Build output, source map, or compiled bundle ends up with the secret inlined. Agent committed dist/ (which it shouldn’t have anyway).

How to spot it: Secret is in a generated file. dist/, build/, out/, coverage/ shouldn’t be in git at all.

Shortest path to fix

Ordered by urgency. Steps 1 and 2 must run within 5 minutes if the commit was pushed.

Step 1: Rotate the secret immediately

Assume compromised. Treat any pushed secret as public from the moment it landed — public-repo scrapers and bots routinely flag fresh keys within seconds, and GitHub itself notifies many providers (Stripe, OpenAI, AWS) so they can auto-revoke. Rotate now:

Stripe   → Developers → API keys → roll the affected key
OpenAI   → API keys → revoke, then create new
AWS      → IAM → Security credentials → deactivate the key, create new
GitHub   → Settings → Developer settings → Personal access tokens → revoke, regenerate

Update your .env.local and your deployment platform’s env vars (Vercel, Netlify, Railway, etc.) with the new value, then confirm production still works with the new key. Old key stays revoked, not just rotated — leaving it active “for a day to be safe” defeats the point.

Do not skip this even if you “caught it before pushing.” The file existed on disk; if your machine was backed up, synced to cloud storage, or indexed by any tool, the value may already have escaped.

Step 2: Remove from git history (if pushed)

For unpushed commits:

# Easy case: not pushed yet, secret is in HEAD only
git reset HEAD~1
# Edit/remove the secret from the file
git add <other-files-only>
git commit -m "..."

For pushed commits, rewrite history. As of June 2026 git filter-repo is the tool Git’s own docs point to (filter-branch is deprecated and easy to corrupt); BFG Repo-Cleaner is a faster, simpler alternative if you only need to scrub a file or string.

# Use git-filter-repo (recommended over filter-branch)
pip install git-filter-repo

# Strip a specific file from all history
git filter-repo --invert-paths --path .env.local

# Or strip a specific string from all blob content (replaces with ***REMOVED***)
echo "sk_live_REAL_KEY_HERE==>REMOVED" > /tmp/secret-replacements.txt
git filter-repo --replace-text /tmp/secret-replacements.txt

# Force-push (coordinate with team — this rewrites history)
git push --force-with-lease origin main

Confirm the secret is actually gone from every commit before you stop:

git log -p -S 'sk_live_REAL_KEY_HERE' --all   # should print nothing
git grep -n 'sk_live_REAL_KEY_HERE' $(git rev-list --all)   # should print nothing

After force-push, every collaborator must re-clone or rebase onto the rewritten history. Anyone with a fork or a stale local clone may still hold the secret — which is exactly why Step 1 (rotation) is non-negotiable.

If the repo is public, the secret was public the instant it landed. Rotation is what closes the hole; the history rewrite only reduces casual discovery, it does not undo the exposure.

Step 3: Hard-block broad git add with a deny rule

A CLAUDE.md instruction is guidance the model can ignore. A permissions.deny rule in settings.json is enforced by Claude Code itself — denied commands never run, and a user-level deny cannot be overridden by a project-level allow. As of June 2026 Claude Code evaluates permissions in deny → ask → allow order, so this is the strongest single backstop available.

Add to ~/.claude/settings.json (user-level, covers every project) or .claude/settings.json (project-level):

{
  "permissions": {
    "deny": [
      "Bash(git add .)",
      "Bash(git add -A)",
      "Bash(git add --all)",
      "Bash(git add:*)"
    ]
  }
}

Note that matching is against the literal command string, so include each variant; Bash(git add:*) then catches anything else with arguments. If that last line is too aggressive for your workflow (it blocks all git add with args), drop it and keep the three explicit forms.

Back it with a CLAUDE.md policy so the model also understands the intent and reaches for the right command:

## Git policy

NEVER use `git add .`, `git add -A`, or `git add --all`.
ALWAYS stage explicit paths: `git add src/billing.ts src/billing.test.ts`
  (or `git add -p` to accept individual hunks).
Before EVERY commit, run `git status` and confirm only intended files are staged.

Without a broad add, secrets can’t be swept in by accident — this layer kills cause #1 outright.

Step 4: Install a pre-commit secret scanner

Choose one:

# git-secrets (AWS-style patterns)
brew install git-secrets
git secrets --install
git secrets --register-aws

# detect-secrets (Python, more configurable)
pip install detect-secrets
detect-secrets scan > .secrets.baseline

Or wire it through the pre-commit framework so it runs for everyone on the team:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/Yelp/detect-secrets
    rev: v1.5.0
    hooks:
      - id: detect-secrets
        args: ['--baseline', '.secrets.baseline']

Then pre-commit install. Now every commit (the agent’s or yours) is scanned before it is created. Prefer gitleaks instead? Add its hook the same way (repo: https://github.com/gitleaks/gitleaks, id: gitleaks) — same protection, broader provider patterns out of the box.

Step 5: Harden .gitignore and add a .env.example check

# .gitignore
.env
.env.local
.env.*.local
.env.production
secrets/
*.pem
*.key
*.p12
config/secrets.json

Add CI check that .env.example doesn’t contain real-looking values:

# scripts/check-env-example.sh
if grep -E 'sk_(live|test)_[a-zA-Z0-9]{20,}|ghp_[a-zA-Z0-9]{36}' .env.example; then
  echo "Real-looking secret in .env.example"
  exit 1
fi

Step 6: Turn on GitHub push protection + CI scanning

Pre-commit hooks only fire if a developer installed them. Two server-side layers run regardless.

Push protection is the one to enable first. It blocks a git push before the secret reaches the remote and shows the offending file, line, and secret type, so the credential never enters history at all. As of June 2026 it is free on public repositories (and on private repos with GitHub Secret Protection), and GitHub expanded coverage in March 2026 to dozens of new providers, with push protection on by default for many of them (Vercel, Supabase, Databricks, Shopify, and others). Enable it at repo Settings → Code security → Secret scanning → Push protection.

CI scanning catches anything that slipped past — useful for secrets already in history or in formats push protection doesn’t know:

# .github/workflows/secret-scan.yml
on: [push, pull_request]
jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

How to confirm it’s fixed

Run through this checklist before you call the incident closed:

  1. Key is dead. Hit the provider with the old key (e.g. a curl test call) — it should return 401/revoked, not succeed.
  2. Gone from history. git log -p -S '<secret>' --all prints nothing.
  3. Not staged anywhere. git status shows no .env* or secret file, and git check-ignore -v .env.local confirms it’s ignored.
  4. Deny rule live. Ask Claude Code to run git add . — it should refuse with a permission-denied message, not stage files.
  5. Scanner fires. Drop a fake sk_live_TEST123... into a file and try to commit — the pre-commit hook should block it.

Prevention

Four independent layers, ordered strongest first — each one catches what the previous missed:

  • Deny rule in settings.json: hard-block git add . / -A / --all so the agent literally cannot run them; back it with a CLAUDE.md policy
  • Pre-commit scanner (detect-secrets, gitleaks, git-secrets) installed via pre-commit so it runs for the whole team
  • GitHub push protection on, so a leaked key is blocked before it ever reaches the remote
  • CI scan (gitleaks-action) as the always-runs backstop

Plus the hygiene that removes the bait entirely:

  • Every secret lives in .env.local, gitignored; .env.example holds placeholders only
  • No real keys in tests, fixtures, or docs — use obvious fakes like sk_test_FAKE_xxx
  • Quarterly: audit git log -S '<prefix>' for old leaked secrets and rotate any still in history
  • For any pushed leak: rotate first, rewrite history second, never the other way around

FAQ

I caught it before pushing — do I still have to rotate the key? Yes, if the secret is a real live credential. The value sat on disk in plaintext; if your machine syncs to cloud storage, gets backed up, or is indexed by any background tool, it may already be out. Rotation is cheap; assuming you’re safe is not.

The repo is private — is a pushed secret still a problem? It’s lower-risk than public, but treat it as compromised. Every collaborator who pulled has it in their local history, and a private repo can be made public, forked, or have its access tokens leaked later. Rotate, then rewrite history.

Can’t I just delete the file in a new commit instead of rewriting history? No. A follow-up commit removes the file from HEAD, but the secret stays in the earlier commit’s blob forever — git log -p and GitHub’s history view still show it. You must rewrite history (or rotate, which makes the old value worthless).

Will the permissions.deny rule break normal commits? No. It only blocks the broad git add . / -A forms (and, if you add Bash(git add:*), all argument forms). Staging explicit paths or using git add -p still works. If a project genuinely needs broad adds, scope the deny to user-level only and override per-project.

Does GitHub push protection cost money? For public repositories it’s free as of June 2026. On private repositories it requires GitHub Secret Protection. Either way, the pre-commit scanner in Step 4 gives you equivalent local coverage at no cost.

Why rotate before rewriting history, not after? Because rewriting takes minutes to coordinate (force-push, everyone re-clones) and never reaches forks or existing clones. During that window the live key is exploitable. Rotation makes the leaked value worthless immediately, so do it first every time.

Tags: #Claude Code #Debug #Troubleshooting #Security #Secrets