Copy a failing request from DevTools as curl, paste it into an AI coding agent and ask why it returns 401. The Authorization header goes to the model with the rest of the message. Nothing in Claude Code checks for it first.
So I added a check that does. It reads each message before it is sent and stops the ones that hold an API key.
Why it matters
Developers and testers paste things into AI coding agents all day: a failing curl command, a request copied from DevTools, twenty lines of a CI log, a config file that will not load. Any of those can carry an Authorization header, a session cookie or a key. Once it is in the message, it goes to the model with everything else, and a key that has left the machine has to be rotated.
I keep my Claude Code setup in an open-source repository, and this check is one of its hooks: a script Claude Code runs at a fixed point, here on every message before the model sees it.
What did not work
A rule in the agent's instructions. "Never handle secrets" is a rule for the agent. The key comes in with the paste, and by the time the agent reads any rule, the message has already been sent.
Masking the key before it is sent. My first idea was to find the key, replace it with ghp_… and send the rest. Claude Code runs a UserPromptSubmit hook on every message before the model sees it, but that hook can only block a message or let it through. It cannot rewrite it.
So the check had to be a hook, and a hook that blocks.
What worked: block only what is certainly a secret
A guard that blocks on a guess gets bypassed by habit. If every third message is stopped, people start typing the word that sends a message anyway, without reading the warning. So the hook has two levels:
- Block: forms that are certainly secrets. GitHub tokens (
ghp_,github_pat_and the other kinds), Anthropic keys, AWS access key ids, private key blocks, JWTs, and a credential header or session cookie whose value looks real (more on that below). - Warn only: anything else that looks like a secret. When gitleaks, an open-source secret scanner, is installed, the message also goes through
gitleaks stdinwith a 2-second budget; on my machine it answers in about 20 ms. A gitleaks rule for one of the certain forms blocks. A generic rule such asgeneric-api-keyonly warns.
When it blocks, it says where and what, never the value. Here it stops a curl command in Claude Code:
The token in the prompt is a fake one, covered here anyway. The hook's own message shows only ghp_ct…5VYy.
The value is masked so the warning does not become one more place the key is written.
Headers: check the name or the value?
This was the real design question, because a pasted request is mostly headers.
- Check only the header name, and every doc that says
Authorization: Bearer $TOKENis blocked. - Check only the value, and every commit hash and UUID looks like a key.
So the hook needs both: a credential header (Authorization, X-API-Key, X-Auth-Token and a few more) or a session cookie, and a value that looks real. "Looks real" is deliberately plain (comments added here):
def looks_real(value: str) -> bool: """A header value that is a credential, not a placeholder or a word.""" value = value.strip() # short, a placeholder ($TOKEN, <your-key>, xxxx...), or too few distinct characters if len(value) < 16 or PLACEHOLDER.search(value) or len(set(value)) < 6: return False # a real credential mixes letters and digits return bool(re.search(r"[A-Za-z]", value) and re.search(r"\d", value)) $TOKEN, <your-key>, xxxxxxxxxxxxxxxx and process.env.API_KEY pass as placeholders. A Basic value is decoded first and blocks only when it holds user:password.
Two ways out exist on purpose. [secret-ok] at the start of a message sends it anyway, with a warning, for the day the "secret" is a test fixture the agent needs to see. CLAUDE_SKIP_SECRET_GUARD=1 turns the hook off. And if the hook itself crashes, the message goes through with a "could not scan" warning: a broken guard must never trap a message.
The result, and what it still cannot do
Now a message like the one at the top stops before it is sent. In a test with a fake GitHub token, the block took zero model turns, so nothing was spent.
The same test showed what a block does not do. The blocked message is not gone: its full text was still written once to the session transcript on disk. The guard keeps a secret from the model, not off the machine. A key that was pasted still needs rotating.
How I know the hook works:
- 18 tests, with fake keys built at run time, so the repository holds nothing a scanner would take for a key.
- 18 deliberate bugs. I broke a copy of the hook 18 ways and checked that a test failed each time. One break survived at first: removing the distinct-character rule failed no test, because the only dummy value in the tests,
xxxx…, was already caught as a placeholder. A case withab12ab12ab12ab12ab12now covers it.
What I'd tell any team using AI coding agents
- Guard the message, not the agent: the key comes in with the paste.
- Block only certainties and warn on guesses, or the way out becomes muscle memory.
- For headers, judge the name and the value together.
- Know what a block does not do. The text can still sit on disk, so rotate a pasted key anyway.
The hook is one Python file, standard library only: hooks/secret_guard.py in my open-source Claude Code setup (Apache 2.0). docs/MECHANISMS.md lists what it enforces, what it does not, and how it was tested.


