How to keep secrets out of Claude Code (185 of ours got in)
185 real secrets in 3,254 of our Claude Code sessions; 80% never passed our gateway. How we kept them out of the model with hooks, and the free plugin.
We counted what our own coding agent had seen. Four months of Claude Code sessions, every transcript, every file it opened and every command it ran. We expected a handful of accidents. We found 185 real credentials.
That hurt more than it should have, because keeping credentials away from the model is our job. We build mcpgate, an MCP gateway: a value that flows through it reaches the model as a placeholder, and the real value goes back in only where the real call happens. So the first question was obvious. How many of those 185 had the gateway ever seen?
185 secrets in 3,254 Claude Code sessions
Claude Code keeps every session as a transcript on disk. We ran a secret detector over 3,254 sessions, 18 May to 7 September 2026. We counted each value once, and we printed no value, only counts and fingerprints.
80 of the 185 have an unmistakable shape: a GitLab token, an AWS key, a
Slack token, a private key. The detector also found 284 values by context, such as
password = … in a config file. We checked those by hand: 105 were real.
The rest were no secret at all (129), short-lived or narrowly scoped tokens (45), test fixtures
(4) and one example from a specification.
We had pictured a person pasting a token into the chat. That was not the pattern.
52 of the 80
unmistakable ones reached the model in the result of a Read or a
Bash call: the agent doing its job. It opens .env to understand a
deploy, it reads a config file to fix a bug, it looks into a container. The single worst call
was one command that printed a running container's environment: eight credentials at once,
in full, to the model.
Everything eventually gets glued together into a sequence of tokens and fed to the model.
The same count for email addresses
Credentials are not the only thing the agent passes on. We ran the same count for email addresses, over the same sessions and the same window: 1,048 distinct addresses of people and organisations outside our own domains reached the model, and 422 addresses of our own colleagues. Again, most of them never passed the gateway.
| Addresses that reached the model | Distinct | Never passed the gateway |
|---|---|---|
| Organisations outside our domains | 906 | 738 (81%) |
| Private freemail addresses | 142 | 100 (70%) |
| External, total | 1,048 | 838 (80%) |
| Our own colleagues (internal domains) | 422 | 313 (74%) |
Not counted: 537 machine and test addresses (commit bots, service accounts,
example.com) and 41 role addresses such as support@. Unlike
the credentials, these addresses were not checked by hand, so read the numbers as an upper
bound.
Why our MCP gateway never saw 80% of them
A gateway sits between the AI client and the company's tools. It sees what the agent asks those tools, and what they answer; we wrote about what flows through a gateway and what gets logged. It does not see the agent reading a file on the laptop, because that never leaves the laptop, except towards the model. For 80% of the secrets, the gateway was simply not in the room.
The other two bars were the gateway's own business, and they taught us something too. The 27 that came out of gateway responses were real bugs on our side: an API that lists CI variables returns their values, and we passed them on. The gateway now keeps them back. The 10 that went into a gateway call were the hardest case: a database password for a new connection, a webhook secret. To type the value into the call, the model had to know it. The gateway received it after the model had already seen it.
Fixing the gateway was the comfortable path. It is our code, and we know it well. It was also the wrong path for four out of five secrets.
We are not the only ones who see the leak on this side of the gateway:
…prompts, command output, debug logs, and session history can capture a credential when redaction is absent or incomplete.
How to keep secrets out of Claude Code today
There is a ladder of options. Each step closes more, and each has a cost.
1. Deny rules: Read(./.env)
permissions.deny with
Read(./.env) in .claude/settings.json (project) or
~/.claude/settings.json (user) stops the Read tool on that file. It does not stop
cat .env, env or a container dump in Bash: the path
most of our secrets took.
{ "permissions": { "deny": ["Read(./.env)", "Read(./.env.*)"] } }
2. The sandbox
Claude Code's sandbox can keep commands away from credential
files as well. It protects the files you list, not values that arrive another way. This is
the one place where .env is special: a sandbox deny stops a command like
cat .env | curl -d @- … from sending the file on without anyone reading it.
3. A blocking PreToolUse hook
A PreToolUse hook can refuse any command that
touches a secret. It also stops the task the agent was doing.
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "secret in command"
}
}
4. A redacting PostToolUse hook: updatedToolOutput
A PostToolUse hook can return
updatedToolOutput and replace the value before the model sees the result. Now the
model is safe, but it cannot use the value either. The hook prints this JSON; the result must
keep the tool's own shape, or Claude Code ignores it for a built-in tool:
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"updatedToolOutput": {
"stdout": "DB_PASSWORD=⟦SECRET_c1⟧\n",
"stderr": "",
"interrupted": false,
"isImage": false
}
}
}
5. Redact and resolve: updatedInput
Store the value locally, give the model a placeholder,
and let a PreToolUse hook put the real value back through
updatedInput right before the call runs. The model never sees it, and the task
still works. That is the step we built, and you can install it in two lines:
claude plugin marketplace add Mcpgate-de/maisecrets
claude plugin install maisecrets@maisecrets
Claude Code hooks can rewrite, not only block: the weeks we missed it
Claude Code has hooks: small programs that run before a tool call, after a tool call, and when you submit a prompt. The obvious idea is a hook that swaps the secret for a placeholder. For weeks we believed that hooks could only block a call, not change it. It was written in the first lines of our own issue on secret handling, and two designs grew out of that one sentence:
- A wrapper. Forbid the agent's own
ReadandBash, and offersecure_readandsecure_bashas MCP tools that scrub. The hole: every tool that is not wrapped becomes the new leak, and there are many. - A local proxy in front of the model API that rewrites every request. It works for any client, but it is a network component on every laptop, and it breaks when a vendor changes its wire format.
Both were heavy. We kept postponing.
Then a colleague in operations showed us what they had done in the meantime. They had forbidden Claude Code to read the files that hold the secrets, and written scripts that send those secrets straight from there, with no AI in between: the agent calls the script, the script uses the value, and the model never sees it. It works. It is also tedious: one script per secret and per task, a new one for every new job, and only its author can maintain them. Almost nobody keeps that up for long. And even with the greatest care, mistakes happen: one new file that the deny list does not name, one command that prints more than expected. We are the proof; keeping secrets away from the model is our job, and 185 got through anyway. If a careful operator builds this by hand, the problem is not ours alone, and most people who have it will never write those scripts, or will stop.
One day: PostToolUse can rewrite what the model sees
We started by reading the hook documentation again. Not skimming it:
reading it. Then we wrote a hook that returned a changed tool result, and ran it. The belief
was wrong. PostToolUse can replace a tool's result with
updatedToolOutput before the model sees it. PreToolUse can replace a
tool's input with updatedInput before it runs. Only the prompt you type cannot be
rewritten, only blocked. That is enough for every place in the chart above. The
documentation even says so:
For redaction or transformation use cases, intercept at PreToolUse for outbound tool inputs and PostToolUse for inbound tool results.
One caution, because you may find it too: an older bug
report says updatedToolOutput
was ignored for the Bash tool in Claude Code 2.1.163 and 2.1.177. Our test harness
checks exactly that path on every change, and it passes on 2.1.223 and 2.1.283. If a release
breaks it again, the harness fails before a user notices.
Morning: a harness that sees every byte sent to the model
The first commit landed at 11:21: a detector, a local vault, three hooks, and a harness that runs the real Claude Code against a fake model server, so we could see every byte that would have left the machine. A blocked prompt sent zero requests. It also taught us the first surprise: Claude Code writes the prompt to its transcript on disk before the hook runs. Blocking was not enough; the plugin also has to scrub that record afterwards. By one o'clock the homemade patterns were gone, replaced by the secret shapes of gitleaks, the PII patterns of Microsoft Presidio and the keyword rules of detect-secrets, loaded as data.
Afternoon: the placeholder any text could guess
We asked for a hostile review of our own design: assume the text
the agent reads is written by an attacker. The reviewer did not argue. It ran a probe. The
probe wrote a placeholder that no human had ever typed, SECRET_c1, into a
command, and printed one line:
guessed SECRET_c1 resolves: True
Placeholders were numbered, so any text could guess one, and the hook would fetch the value for it. Our vault had become an oracle for whoever could get a sentence in front of the agent: a README, an issue, a web page. Fifteen minutes later a placeholder resolved only in the session where a human had typed it, through a one-time grant, under a rate cap, with an audit log. The same afternoon we found the quietest failure of the day: one manifest key made an older Claude Code release reject the plugin and load no hook at all, without a word. A guard that disappears silently is worse than none. The key went, and the test harness now fails when that happens.
Evening: the plugin finds its own gaps
The plugin now ran in our own sessions, and our own sessions found the next bugs. While we set up the web page for it, a new Cloudflare token format went through without the word "cloudflare" next to it. Minutes later it was a rule and a test. By the end of the day the same hook file also ran in OpenAI's Codex, and the tests passed on macOS, Linux and Windows, about a hundred commits after the first one.
maisecrets: a free plugin for Claude Code and Codex
maisecrets, the plugin that keeps secrets out of Claude Code and Codex, works in three places. It covers keys, tokens, labelled passwords and personal data:
- What you type. The prompt stops on your machine. You get it back with a
placeholder; paste it, or type
/msto send it as rewritten. If the agent needs a password for the task, label it (password: …) or use/maisecrets:put, and keep working. - What the agent reads. File contents and command output reach the model
with placeholders instead of values. Detection does not depend on the file name:
cat config.yaml,git diff,kubectl get secretor apsqldump are scanned the same way as.env. - The real call. When the agent uses a placeholder in a
curl, a deploy or a call to an MCP server or gateway, the value goes in right before execution. The service gets the secret. The AI never sees it. That also closes the hardest case above: the database password now reaches the gateway call without passing through the model.
The gateway and the plugin now do the same thing on two sides. The gateway protects what goes through it. maisecrets protects what never reaches it.
What it does not do
- Chat apps, the web and the mobile apps run no plugin hooks. There, it cannot help.
- It finds shapes, not meaning. A bare password in a sentence is not detected, and a value that the agent Base64-encodes passes.
- A process that runs as you can still read the vault, like any keychain. The guards stand in front of the agent, not in front of you.
- An
@.envmention inlines the file into your prompt, past the tool hooks. The prompt hook blocks such a mention when the file exists; ask the agent to read the file instead, and the output guard applies.
Questions about Claude Code and secrets
Does Claude Code read my .env file?
It reads whatever the task leads it to, unless a deny rule, the sandbox or a hook stops it. In our sessions, 52 of 80 unmistakable tokens reached the model through the result of a Read or Bash call.
Can a Claude Code hook change what the model sees?
Yes. A PostToolUse hook can replace the tool result with updatedToolOutput, and a PreToolUse hook can replace the tool input with updatedInput. Only a typed prompt cannot be rewritten; a UserPromptSubmit hook can only block it.
Do deny rules stop secrets from leaking?
They stop the tool they name, on the path they name. Read(./.env) does not stop cat .env in Bash, and no rule stops a value that a command prints.
Does this work with OpenAI Codex?
Yes. Codex runs the same hook file. Trust the hooks once in /hooks; until you do, Codex runs none of them.
Were we first?
We thought so, and we checked before writing this. We were not. VaultMCP, a small open-source project started in June, describes a similar design for Claude Code: vault the value, redact the output, insert it at execution. Other tools cover one piece: hooks that only block or redact (sensitive-canary, redact-hook, cc-redact, claude-code-redaction-hooks), an environment-variable handoff (nopeek), deny rules, gateways and proxies that anonymise API traffic on a server, and password managers that fill browser logins for an agent. We found no other tool that does all three locally for coding agents.
That the same idea shows up twice in one summer tells us the problem is real, not that it is solved.