Back to posts
AINews

A Claude Code approval can store your password in plain text. How to find it, rotate it, and keep the file out of git

When you answer a Claude Code permission prompt with \"Yes, and don't ask again\", the approved command is saved word for word in .claude/settings.local.json, including any password typed into it. Apiiro counted 1,122 public GitHub copies of that file holding a real secret, and in our own test gitleaks with its default rules caught one of five password shapes. Claude Code keeps the file out of git through your global git excludes, which stay on one machine. The three git commands that show whether a repository ever committed it, what to rotate first, and how to write commands so the saved rule records nothing secret.

"Bash(PGPASSWORD=Harbor-Maple-7731 psql -h db.acme-prod.example.com -U app -c \"select count(*) from users\")"

That line is an allow rule. Claude Code writes rules like it to .claude/settings.local.json when someone answers a permission prompt with "Yes, and don't ask again", and this one holds a database password because the approved command did. The password above is made up. The ones security firm Apiiro counted this week are not.

Apiiro searched public GitHub from November 1 to 3 and listed 104,185 copies of .claude/settings.local.json in 103,617 repositories. In its published research, 1,122 of those files hold what it classes as a real secret: a key in a known provider format, or a password or token on the same line as a public host. Together they hold 1,379 distinct secrets, among them 379 database passwords, 122 Supabase service-role keys, 43 SSH logins and 23 AWS keys. Adding versions deleted from the current files, the shared .claude/settings.json and agent transcripts committed to repositories, the total is 2,539 secrets in 1,994 repositories. Apiiro did not test whether any credential still works; one company it notified confirmed its key had been live. The rate climbs with file size: one file in ten over 8 KB held a real secret, against fewer than one in 500 under 500 bytes, so the longer someone has been clicking through prompts, the more the file holds.

The ignore rule lives on your machine, not in the repository

Claude Code does try to keep this file out of commits. Its settings documentation says that the first time it writes the file in a git repository that does not already ignore it, it adds **/.claude/settings.local.json to your global git excludes file: core.excludesFile if your global config sets it to an absolute or ~ path, otherwise $XDG_CONFIG_HOME/git/ignore, or ~/.config/git/ignore when that variable is unset. Boris Cherny, who leads Claude Code, made the same point in the Hacker News thread on November 7. Apiiro's point is that the line never goes into the project's own .gitignore. Both statements are accurate, and the leak sits in the gap between them. The protection covers one machine's git config, so it does not hold in four situations:

  • The folder reaches a machine whose global excludes lack the line. A second laptop, a synced or zipped project folder, a container the folder was copied into. Nothing in the repository marks the file as private, so git add -A stages it.
  • The folder became a repository after the file was written. The documented behavior applies when Claude Code writes the file inside a git repository.
  • The file was created by hand. The documentation says to add it to .gitignore yourself in that case.
  • The file is already tracked. Git ignore rules do not apply to tracked files (git help gitignore: "Files already tracked by Git are not affected"), so once one commit includes it, every later approval shows up as a change waiting for the next commit.

We reproduced the first and last cases with a throwaway repository and an empty home directory standing in for a fresh machine. git add -A staged the file. After one commit, putting the excludes line back changed nothing: git reported the next edit as a modification, and git check-ignore reported the file as not ignored. Claude Code itself often makes that commit. In 539 of the 1,105 repositories Apiiro traced, a commit co-authored by Claude changed the file.

Three commands per repository

Run these at the root of each repository you have used Claude Code in. The pathspec '*.claude/settings*.json' matches .claude folders at any depth, and it matches the shared settings.json too, which Apiiro found holding a secret in 298 files.

# 1. Is either settings file tracked right now?
git ls-files -- '*.claude/settings*.json'

# 2. Was one ever committed, including copies deleted since?
git log --all --format='%h %ad %s' --date=short -- '*.claude/settings*.json'

# 3. Does any version in history hold a password-shaped command?
git log --all -p -- '*.claude/settings*.json' \
  | grep -nE 'PGPASSWORD=|sshpass +-p|mysql[^"]* -p[^ "]|[Aa]uthorization: *[Bb]earer|://[^/:@ "]+:[^@ "]+@'

To cover every repository in a GitHub organization at once, GitHub code search accepts org:YOUR-ORG path:.claude/settings.local.json, and the same with path:.claude/settings.json. GitHub code search covers only the default branch, so it misses history; the commands above do not.

The file is worth reading even if it was never committed, because it is plain JSON in the project folder. Apiiro points to the 2025 Shai-Hulud npm worm, which ran TruffleHog over victims' files. This lists every copy under your home directory that matches the same patterns, including paths with spaces:

find ~ -path '*/.claude/settings*.json' -not -path '*/node_modules/*' \
  -exec grep -lE 'PGPASSWORD=|sshpass +-p|mysql[^"]* -p[^ "]|[Aa]uthorization: *[Bb]earer|://[^/:@ "]+:[^@ "]+@' {} + 2>/dev/null

If a command turns up, rotate before you clean

Change the credential at its source first: the database user's password, the provider key, the SSH login. Deleting the rule or the file does not reach copies that already exist. Apiiro found 211 forks of 74 affected repositories carrying the same secrets, and 734 secrets that survive only in earlier versions of files that were later deleted.

Then clean up in this order. Open /permissions, which lists every rule and the settings file it comes from, and remove each rule that contains a secret. If the file is tracked, run git rm --cached .claude/settings.local.json and commit. Then add this line to the repository's own .gitignore, so the protection travels with the project instead of staying in one person's git config:

**/.claude/settings.local.json

We checked that pattern with git check-ignore -v; it matches the file at the root and in nested folders such as apps/web/.claude/. Teams can put the same line into their organization's .gitignore templates.

A scanner tuned to key formats lets most of these through

Apiiro ran gitleaks and TruffleHog, without verification, over all 104,185 files. Of the 1,122 with a real secret, gitleaks flagged 564 and TruffleHog 643, and 215 were flagged by neither.

We ran gitleaks 8.30.1 with its default rules over a test file holding five made-up rules. It flagged one, the PGPASSWORD= line, as generic-api-key. It passed the password after sshpass -p, the password glued to mysql -p, a bearer token in a curl -H header, and a postgresql://app:password@host URL. The grep in step 3 matched all five. Scanners look for key shapes, such as a provider prefix followed by a long random string, and a password after a command-line flag is an ordinary word. If your pre-commit hook or CI scan only runs the default rules, add those patterns as custom rules, and point the scan at .claude/ and other agent folders as well.

Commands that leave the saved rule with nothing worth stealing

The lasting fix is to keep the secret out of the command line, so the approval has nothing to record. Each of these tools can read the credential from somewhere else:

What you approvedWhat the saved rule holdsWhere the credential can live instead
PGPASSWORD=… psql -h db.example.comThe database password~/.pgpass (format hostname:port:database:username:password, chmod 0600 or psql ignores it), or a file named by PGPASSFILE
psql postgresql://app:…@db.example.com/appThe password inside the URLThe same URL without the password, plus ~/.pgpass
mysql -u admin -p… -h db.example.comThe passwordThe [client] section of ~/.my.cnf with mode 400 or 600, or --defaults-file= pointing at a file outside the repository
sshpass -p … ssh [email protected]The SSH passwordAn SSH key. Where a password cannot be avoided, sshpass -e reads it from the SSHPASS environment variable
curl -H "Authorization: Bearer …"The tokencurl -H @/path/to/header-file, which reads one header per line from the file (curl 7.55.0 and later)

There is a second reason to change the PGPASSWORD= form in particular. Claude Code's permissions documentation says it strips a leading assignment only for a set of known-safe environment variables, and an allow rule does not match past an assignment of any other variable. So Bash(psql *) does not approve PGPASSWORD=… psql -h db.example.com, and a rule saved from the prompt for that command starts with the assignment, password included. Once the command is psql -h db.example.com, a pattern rule such as Bash(psql -h db.example.com *) covers it and stores no secret.

Two smaller habits reduce how often the value gets typed at all. A Read(./.env) deny rule keeps Claude's file tools from reading that file, which is one common way a connection string ends up pasted into a proposed command. And when a prompt shows a long command, read it for a credential before choosing the option that saves it. Apiiro ties the leaks to prompt fatigue, since a secret is one word in a long line, and its leak rate rises with the size of the allowlist.

What Anthropic has said so far

On Hacker News, Cherny wrote that Claude Code has historically not done secret scrubbing, that the team is "looking into what we can do to prevent this", and that it will prototype approaches including basic secret scanning before shipping one. He also recommended auto mode, where a classifier reviews actions instead of you approving each prompt, and secret scanning on the code host. Until a change ships, nothing in the tool stops a typed password from being saved in an approval. Users had reported parts of this in four GitHub issues since August 2025 (#5544, #11982, #13106, #91270); all four are closed, as not planned or as duplicates. The last one, a single user's report from Windows, describes the VS Code extension saving approvals to the shared, committed settings.json, which is one more reason the commands above check both files.

The allowlist is one place an agent records what you typed. Apiiro found the same password-in-a-command pattern in committed agent transcripts in 87 repositories, in agent memory files in 9, and in handoff and deploy notes, a third of them written by the agent itself. The patterns in step 3 work on those files too. For how other Claude Code permission rules can fail to hold, see how a plugin can lift your deny rules and the guard hook that fails open; for the terms, see guardrails.

Get the next post when it ships

One email on Sunday with the new post and a short list of what shipped that week — new guides, tool updates, and a couple of links worth reading.