Posted on · 5 min read
Claude Code's "Don't Ask Again" Leaked API Keys to GitHub: Find Yours and Check if They Still Work
On this page · 8 sections
When Claude Code asks to run a command and you pick "Yes, and don't ask again", it remembers that command in .claude/settings.local.json. That's convenient. It also means that if the command had an API key in it, say a curl with access_key=... on the end, the key is now sitting in a plain JSON file in your project.
In early November 2026, security firm Apiiro reported that those files are ending up on GitHub. The Hacker News counted real secrets in 1,122 public copies: database passwords, cloud keys, tokens and API keys. Claude Code does try to stop this by adding the file to a global git-ignore list. Boris Cherny from the Claude Code team said on Hacker News that the tool hasn't historically scrubbed secrets, and that the team is looking at ways to prevent this.
We use these API keys every day, so we checked how it happens, then wrote two small JavaScript tools. One finds API keys in a repo, including its git history. The other tells you whether a key it finds still works. Everything below ran on November 12, 2026.
#What Ends Up in settings.local.json
The file holds the commands you've allowed. With a fake key, ours looked like this:
{
"permissions": {
"allow": [
"Bash(npm run dev)",
"Bash(curl \"https://api.ipstack.com/8.8.8.8?access_key=0123456789abcdef0123456789abcdef\")",
"Bash(curl \"https://api.aviationstack.com/v1/flights?flight_iata=UA1&access_key=fedcba9876543210fedcba9876543210\")"
]
}
}That's exactly how you test an API from the terminal, and exactly how a coding agent tests it for you. APILayer APIs like ipstack take the key as an access_key query parameter, so the key is part of the command, and the command is what gets saved.
#Why the Git-Ignore Didn't Save Everyone
We committed that file in a fresh test repo, and git refused to pick it up. Here's why:
$ git check-ignore -v .claude/settings.local.json
/Users/you/.gitignore:1:**/.claude/settings.local.json .claude/settings.local.jsonClaude Code had added **/.claude/settings.local.json to the global ignore file on our machine. That's the protection Apiiro mentions, and it works. On this machine.
A global ignore lives in your home directory, not in the repo. A teammate who hasn't run Claude Code, a fresh laptop, a CI job or a git add -f doesn't have it. We simulated a machine without it, and the file went straight in:
$ git -c core.excludesFile=/dev/null add -A && git commit -m "initial commit"
$ git ls-files
.claude/settings.local.json
index.jsThen we did what most people do when they notice: added it to the repo's .gitignore and untracked it.
$ git rm --cached .claude/settings.local.json
$ git commit -m "stop tracking claude settings"
$ git log --oneline -- .claude/settings.local.json
bf8606e stop tracking claude settings
67b27b8 initial commitThe file is gone from the working tree, and both keys are still in the first commit. git show 67b27b8:.claude/settings.local.json prints them. If that repo was ever public, or anyone forked it, the keys are out. Untracking the file stops the next leak. It doesn't undo this one.
#Finding Keys in Files and Git History
So a scanner has to read history, not just files. Ours runs git log -p over every branch, then reads every file on disk, including ignored ones. It looks for 32-character keys after access_key= or an apikey header or field, which is the format APILayer keys use:
// find-keys.mjs: find APILayer access keys in a repo's files AND its git history
import { execFileSync } from "node:child_process";
const repo = process.argv[2] ?? ".";
const git = (...args) => execFileSync("git", ["-C", repo, ...args], { encoding: "utf8", maxBuffer: 1 << 28 });
// access_key=<32 chars> in a URL, or an apikey header / JSON field.
const KEY = /(?:access_key=|apikey["']?\s*[:=]\s*["']?)([a-z0-9]{32})\b/gi;
const HOST = /https?:\/\/([a-z0-9.-]+)\//i;
const found = new Map(); // key -> { hosts, where }
function scan(text, where) {
for (const line of text.split("\n")) {
for (const m of line.matchAll(KEY)) {
const hit = found.get(m[1]) ?? { hosts: new Set(), where: new Set() };
const host = line.match(HOST)?.[1];
if (host) hit.hosts.add(host);
hit.where.add(where);
found.set(m[1], hit);
}
}
}
// Every version of every file ever committed, on every branch. "Deleted" files live here.
scan(git("log", "--all", "-p", "--no-color", "--format=commit %h"), "git history");
// Files on disk, including ignored ones like .claude/settings.local.json.
for (const f of git("ls-files", "--cached", "--others").split("\n").filter(Boolean)) {
try { scan(execFileSync("cat", [`${repo}/${f}`], { encoding: "utf8" }), f); } catch {}
}
const mask = (k) => `${k.slice(0, 4)}…${k.slice(-4)}`;
for (const [k, v] of found) console.log(mask(k), [...v.hosts].join(","), "|", [...v.where].join(", "));On the test repo:
$ node find-keys.mjs ./demo-repo
0123…cdef api.ipstack.com | git history, .claude/settings.local.json
fedc…3210 api.aviationstack.com | git history, .claude/settings.local.jsonBoth keys turn up twice: in the history and in the ignored file on disk. The output masks keys to four characters at each end, so you can paste it into a ticket without leaking them again. The regex is deliberately narrow: it catches APILayer-style keys and little else. For every kind of secret, use a dedicated scanner like gitleaks or GitHub's secret scanning alongside it.
#Is the Leaked Key Still Live?
A key in an old commit might have been rotated months ago, or it might be the one paying for production. You want to know which before you panic, or before you relax. The simplest test is one cheap request with the key.
First we checked what a dead key looks like. We sent the same fake key to seven APILayer APIs, and they all answered HTTP 401, in two different shapes:
{"success":false,"error":{"code":101,"type":"invalid_access_key","info":"You have not supplied a valid API Access Key. [Technical Support: support@apilayer.com]"}}That's ipstack, mailboxlayer and numverify. aviationstack, marketstack, mediastack and exchangeratesapi.io put the name in code instead:
{"error":{"code":"invalid_access_key","message":"You have not supplied a valid API Access Key. [Technical Support: support@apilayer.com]"}}A checker that only looks at error.type will call every aviationstack key live. So ours reads both:
// check-key.mjs: is a leaked APILayer key still live? One cheap request per key.
const PROBES = {
"api.ipstack.com": (k) => `https://api.ipstack.com/check?access_key=${k}`,
"api.aviationstack.com": (k) => `https://api.aviationstack.com/v1/airports?search=heathrow&limit=1&access_key=${k}`,
"api.marketstack.com": (k) => `https://api.marketstack.com/v2/exchanges?limit=1&access_key=${k}`,
"api.mediastack.com": (k) => `https://api.mediastack.com/v1/sources?search=bbc&limit=1&access_key=${k}`,
"apilayer.net": (k) => `https://apilayer.net/api/validate?number=14158586273&access_key=${k}`,
};
export async function checkKey(host, key) {
const probe = PROBES[host];
if (!probe) return "unknown host";
const res = await fetch(probe(key));
const body = await res.json().catch(() => ({}));
// Two error shapes: {error:{code:101,type:"invalid_access_key"}} and {error:{code:"invalid_access_key"}}.
const code = body.error?.type ?? body.error?.code;
if (code === "invalid_access_key") return "dead";
// Anything else means the key was accepted: a 200, a plan restriction (105) or a rate limit (106) all count.
return `LIVE (HTTP ${res.status}${code ? `, ${code}` : ""})`;
}The last comment is the important rule. Only invalid_access_key means dead. If a probe comes back with a plan restriction or a rate limit, the server recognised the key before refusing the request. Someone else can still spend your quota with it.
We ran it on the two fake keys and on two of our own real keys, which stayed in memory and never in a file or a log:
fake ipstack key dead 855ms
fake aviationstack key dead 678ms
our real ipstack key LIVE (HTTP 200) 301ms
our real aviationstack key LIVE (HTTP 200) 357msRun it on keys from your own repos only. Testing a key you found in someone else's repository is using someone else's credentials, whatever the reason. If you find one, tell the owner.
#What to Do With a Live Key
Rotate it first, then clean up. In that order, because the history is already public and cleaning it doesn't take the key back.
- Generate a new key in the API's account dashboard and put it wherever production reads it (your host's environment variables, not the repo).
- Deploy, then run the checker on the old key. It should come back
dead. If it saysLIVE, the old key hasn't been revoked yet. - Check your usage page for requests you didn't make while the key was exposed.
- Only then rewrite history with
git filter-repoif you want the key gone from the repo, and remember that forks and clones keep their copy.
#How to Stop It Happening Again
Keep keys out of the commands themselves. If you or your agent run
curl "https://api.ipstack.com/check?access_key=$IPSTACK_KEY"the command that gets approved and saved contains the text $IPSTACK_KEY, not the key. The shell fills it in at run time. Tell your agent to do the same in CLAUDE.md or AGENTS.md: "API keys are in environment variables. Never paste a key into a command or a file."
Then add a backstop that runs before every commit. This pre-commit hook blocks any commit that adds an APILayer-style key:
#!/bin/sh
# .git/hooks/pre-commit: block commits that add an APILayer-style access key.
if git diff --cached -U0 | grep -Ei '^\+.*(access_key=|apikey["'"'"']?[[:space:]]*[:=][[:space:]]*["'"'"']?)[a-z0-9]{32}' >/dev/null; then
echo "pre-commit: this commit adds what looks like an API key. Use an environment variable instead." >&2
exit 1
fiWe tested it on the same repo. A file with a literal key was refused, and the same line rewritten to read process.env.IPSTACK_KEY went through:
$ git commit -m "add leak"; echo "exit: $?"
pre-commit: this commit adds what looks like an API key. Use an environment variable instead.
exit: 1
$ git commit -m "use env var"; echo "exit: $?"
exit: 0Hooks in .git/hooks aren't shared through the repo, which is the same weakness as the global ignore. For a team, install them with a tool like husky or lefthook, and add .claude/settings.local.json to the repo's own .gitignore so the rule travels with the code.
#How to Vibe Code the Scanner
If you have an assistant write your version, the prompt needs the two things it would otherwise miss: git history and the second error shape.
Write find-keys.mjs (Node, no dependencies). Scan `git log --all -p` AND every file from
`git ls-files --cached --others` (ignored files included) for 32-character keys after
access_key= or an apikey header or field. Record the host from the same line. Print each key
masked to its first and last 4 characters, with hosts and where it was found.
Write check-key.mjs exporting checkKey(host, key). Make one cheap GET per known host. Treat a
key as dead only when error.type or error.code is "invalid_access_key". Every other response,
including plan restrictions and rate limits, means the key is live.Check the output yourself. Commit a fake key in a scratch repo, untrack it, and make sure the scanner still finds it. Then give the checker an aviationstack key you know is fake and make sure it says dead. That's the case a single-shape checker gets wrong.
#Wrapping Up
The leak is a convenience feature doing what it was told: it saved a command that happened to contain a key. The fix is just as plain. Keep keys in environment variables, scan history rather than just files, and treat any key that isn't invalid_access_key as live until you've rotated it. If you're starting fresh with ipstack or any other APILayer API, set the key up as an environment variable from the first request, and see its Public APIs page for alternatives.
Happy building.