I have not caught a single piece of malware in 25 years on a keyboard. Not one. I spot a .scr disguised as a PDF from across the room. I smell a sketchy postinstall script ten meters away. At 14 I even wrote two or three viruses myself, just to understand the mechanics (the biology of it fascinated me, replication, mutation, persistence). The attacker, I know him from the inside.
This morning I audited my home directory across the last 12 months. 600 secrets in cleartext on my disk 😬. GitHub PATs, OAuth tokens, AWS keys, Google API, JWTs, the whole buffet. Not in a .env forgotten on a public repo. Not in a botched commit. In JSONL files buried inside ~/.claude, a directory whose existence I barely registered two weeks ago.
This is not a mea culpa about bad hygiene. Fifteen years ago I had 100 passwords in Keychain and that was enough. Today we carry dozens of API keys around, tools log them without telling us, and my 25-year discipline was never calibrated for this.
The old rule, "be careful, don't click on random stuff," assumes an attacker who is going after me. That threat is still there. But a second category has shown up: the attacker who is not after me at all, just running with my privileges, planted by a transitive npm dependency I never audited. He lands in a home directory that now contains a museum of everything I ever showed an assistant.
A lot more dangerous. Time to react.
The audit: 5,904 files, 171 touched, 600 secrets in cleartext
I ran the scan against ~/.claude/{projects,tasks,sessions,todos,shell-snapshots,paste-cache,file-history,debug} plus ~/.zsh_history and ~/.bash_history. 5,904 files total. 1.1 GB of cumulative weight. 171 of those files contained at least one credential.
The breakdown looks roughly like this:
- 95 GitHub OAuth tokens (
gho_) - 94 GitHub fine-grained PATs (
github_pat_) - 103 Google API keys (
AIza) - 197 JWT-shaped strings (
eyJ) - 45 AWS access keys (
AKIA) - 18 OpenRouter
- 15 Resend
- 7 Anthropic OAuth
- 6 Telegram bot tokens
- 3 Stripe test keys
- 2 Vercel tokens
Roughly 600 secrets across 171 files. Almost all of them rotatable, many already rotated by the time I'm writing this. One caveat I'll put right here instead of burying at the end: the JWT_LIKE bucket is noisy, it includes Supabase publishable keys that are public by design. I assume the false positives. A false positive costs me a redaction. A false negative costs me a credential.
Reading the JSONL felt like opening an autosave file from a roguelike I never knew I was playing. Every command, every paste, every read, persisted forever in the order it happened. NetHack's persistent dungeon, except the loot is my AWS keys.
Another developer publicly reported a smaller version of the same problem in GitHub issue #50014 on April 17: 5 distinct secrets across 34 session files after roughly 30 days of usage, 418 MB total.
30 days, 5 secrets. 12 months, 600. A linear relationship I find way too believable.
So the question is not whether this happens. It's how it happens, and what defends against it.
Why it happens: five paths to a plaintext transcript
The mechanism is mechanical, not mysterious. Each Claude Code session writes a JSONL file in ~/.claude/projects/<project-hash>/<session-id>.jsonl. Every line is one record: a user message, an assistant reply, a tool call, a tool result. The file is append-only. Nothing prunes it. Nothing scrubs it. It sits there as long as you want, and on most Macs that means forever.
Five paths lead a secret into that file:
Bash output from a legitimate command.
infisical secrets get MY_TOKEN --plain,gh auth token,vercel token,cat .env,security find-generic-password -w,printenv | grep TOKEN,echo $SECRET. Anything that prints a credential to stdout, the JSONL records.Manual paste by you, in the chat. You drop a token into the prompt to ask Claude to use it. The token is now part of the user-message record forever.
File read through the
Readtool. You ask Claude to look at.envfor context. The file content lands in the tool-result record.File write with a hardcoded secret. You ask Claude to scaffold a config and the secret ends up in the new file content. Bonus: the same content gets duplicated under
~/.claude/file-history/.Explicit display by Claude in a reply. You ask "what's the value?", Claude prints it back. The reply is in the assistant-message record.
Five paths, one file, append-only, plaintext. No purge, no rotation, no scan.
Now the pivot. The old defense, "don't paste your secrets in random places, don't run sketchy commands," was built around an attacker who attacks you. That defense still works against that attacker. The problem is that a different category showed up, and it walks around the old defense without breaking it.
I traced the LiteLLM hijack to my own pip cache eight months ago, and the lesson was already there: a poisoned package lands with the user's privileges and starts reading what the user can read. In March 2026, the TeamPCP campaign poisoned 75 GitHub Action tags and pushed malicious payloads to 141+ npm packages through stolen CI/CD secrets. In April 2026, Check Point researchers found 33 npm packages publicly shipping .claude/settings.local.json files with inline credentials. GitHub PATs, Telegram tokens, production bearer tokens, the works.
Three different campaigns. Same mechanic. The attacker is not knocking on my door. He is a script running with my privileges, dropped by a dependency I never audited directly. And once that script is alive in my home directory, my 1.1 GB of Claude Code transcripts is a goldmine.
GitGuardian's 2026 report puts the broader trend in numbers: AI service credential exposures detected on public GitHub jumped 81% year over year. Claude Code-assisted commits leak secrets at roughly 3.2%, against 1.5% for the public-commits baseline. AI-accelerated commits leak secrets at twice the rate. The shift is industry-wide, not just my disk.
So here is the pivot, plain.
Discipline defends against attackers who attack you. Mechanical guardrails defend against attackers who run as you.
Even your vault leaks the moment it does its job
I had Infisical. I had deny-rules. I had runtime injection. I still had 600 secrets in plaintext.
The story is annoying because the hygiene was correct. The secret does not sleep in a .env. It lives in an encrypted vault. And yet.
The mechanic is the resolution step itself. The secret leaves the vault for two seconds to do its job, and those two seconds exist somewhere: in cleartext in bash output, in cleartext in a process env, in cleartext in a manual paste. During those two seconds, Claude Code is listening. The JSONL takes notes.
Watch the difference between two commands that look identical:
infisical secrets get MY_TOKEN --plain
curl -H "Authorization: Bearer $(cat last_token.txt)" https://api...
MY_TOKEN=$(infisical secrets get MY_TOKEN --plain)
curl -H "Authorization: Bearer $MY_TOKEN" https://api...
One character of difference. Two opposite fates. The first one writes the token to the transcript. The second one never lets it touch stdout.
A vault is a lock. A JSONL transcript is a museum. Both keep the secret. Only one keeps it on display.
The 4-layer defense I had to build from scratch
Order matters. The earliest layer fires before the secret touches the disk. The latest layer cleans up what slipped through. You want both. Think of it as staging a raid: each phase cuts the attack surface for the next one, and the last phase cleans the loot the boss dropped on the floor.
Layer 1: PreToolUse hook on Bash. It intercepts the risky patterns before the command runs. infisical secrets get --plain not piped, gh auth token not piped, vercel token, security find-generic-password -w not piped, cat .env|.envrc|.netrc|.npmrc, printenv|env grepping for token|secret|key|password, echo $VAR_SECRET. The hook returns a JSON permissionDecision: ask with a message that explains the safe pattern. Not a strict block. A false positive must not break the workflow, otherwise I'll disable the hook within a week and we both know it.
Layer 2: UserPromptSubmit hook. It scans the text I am submitting before it enters the transcript. Match means decision: block. The pasted secret never makes it into the JSONL. Same regex set as the other layers, plus an extra pattern for full URLs with embedded credentials.
Layer 3: SessionEnd hook + scrubber. When a session ends cleanly, the hook glob-matches ~/.claude/projects/*/<session_id>.jsonl, scrubs the file in place, validates that every line is still valid JSON, writes atomically (tmp file plus os.replace). This brings the leak window from 24 hours down to a few seconds.
Layer 4: Daily cron at 04:00. Net for sessions that died ungracefully. kill -9, crash, power loss, anything where SessionEnd never fired. The cron walks the same paths and does the same scrub. Belt and suspenders.
A behavior rule in Claude's memory ("don't print secrets to stdout") is layer zero, the weakest one. I keep it as a polite hint. The 600-secret audit is enough proof that the model's discipline eventually folds.
A few design choices that matter to anyone who wants to copy this:
The scrubber is Python 3, stdlib only. Zero dependencies. /usr/bin/python3 never breaks during a Homebrew upgrade. Pattern matching is anchored on known prefixes: sk-ant-api, ghp_, github_pat_, AKIA, gho_. No generic "20 alphanumeric characters" regex, that would generate false positives on every UUID, hash, and base64 chunk in the file.
Replacement is deterministic with a sha256 fingerprint: [REDACTED-GITHUB_PAT_CLASSIC-a1b2c3d4]. Same secret in two places redacts to the same fingerprint. I can track duplicates without ever storing the value itself. JSON validity is preserved, the substitution happens inside the existing string field, the structure stays intact.
No pre-scrub backups. A backup file is just another copy of the secret, and a thief reads it as easily as the original.
I caught the very first signal of this whole rabbit hole a month ago when Claude Code refused to copy my own secrets during a folder move. settings.local.json showed up with credentials in plaintext, in a place I had never thought to look. The audit I'm describing here started because I refused to believe that was the only place.
The full code is on GitHub.
The scrubber does not need to be careful. It runs. The hook does not need to be smart. It blocks.
What still leaks, and the new rule I live by
The defense is a perimeter, not a wall. Honest list of what slips through:
Secrets without an identifiable prefix. URLs of the form https://user:pass@host. Arbitrary passwords. UUID v4 used as bearer tokens. Anchored patterns mean good precision but partial coverage. I accept that tradeoff because the alternative is a regex that flags every base64 string and gets disabled within a day.
Bash obfuscation. eval $(echo "infisical secrets get X --plain"), custom aliases that wrap the dangerous command, anything indirect. Layer 1 catches the direct patterns. It does not catch a determined obfuscator (which, to be fair, is mostly me trying to prove the hook wrong).
Scope is strictly Claude. ~/.claude, ~/.zsh_history, ~/.bash_history. Out of scope: ~/.aws/credentials, ~/.ssh/id_*, Keychain via security, env vars in running processes, browser cookies. Other surfaces are other projects.
False positives on JWT_LIKE. Supabase publishable keys get redacted unnecessarily. I'd rather lose a few public keys to redaction than miss a real one.
So here is the new rule.
The old rule was "be careful, don't click on random stuff." It assumed a remote or human attacker. Still valid for that category, and I'm not throwing it out.
The new rule is different.
Nothing secret survives in cleartext on this disk.
Not from paranoia. From pragmatism. Tomorrow a script will run with my privileges, dropped by a dependency three layers deep that I never reviewed by hand, and the only defense that holds is mechanical.
Six months from now
A vendor will announce "massive credential leak via AI assistant transcripts" and everyone will look surprised. There will be a corporate post with vague blame on a third-party storage layer. There will be threads explaining you obviously should have toggled that one setting. Everyone will say it was an isolated case.
Meanwhile some of us are auditing our disks. Hacking together hooks. Reading the source when it leaks. Building nets. Not from paranoia. From pragmatism.
The old rule assumed a human on the other side. That stopped being the default a while ago.
The next layer of defense is teaching our AIs to behave. Our new digital children, non?
Sources
- GitHub issue #50014 — Secret scrubbing for session logs
- The Register — Claude Code's source reveals extent of system access
- VentureBeat — GitGuardian State of Secrets Sprawl 2026
- SecurityBrief — Claude Code can leak secrets in public npm packages
- Daniel Avila — Supply Chain Guard / TeamPCP campaign
Top comments (0)