· 11 min read · Andreas Kruse

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?

YOUR MACHINE LEAVES YOUR MACHINE You type a token in the prompt The agent reads .env file or command output You paste a password for the agent to use The agent Claude Code · Codex reads, runs, calls tools The model Anthropic · OpenAI sees every value in full Gateway → your tools API · deploy · MCP servers sees tool calls only all 185 37 passed here results
Without a guard, every value the agent reads and every value you type goes to the model in full (orange). The gateway sees only tool calls: 37 of our 185 secrets.

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 modelDistinctNever passed the gateway
Organisations outside our domains906738 (81%)
Private freemail addresses142100 (70%)
External, total1,048838 (80%)
Our own colleagues (internal domains)422313 (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

Would the gateway ever have seen the secret?

Never passed the gateway: 148 secrets (80%) Never passed the gateway 148 · 80% Came out of a gateway response: 27 secrets (15%) Came out of a gateway response 27 · 15% Went into a gateway call: 10 secrets (5%) Went into a gateway call 10 · 5%
The same 185 secrets, sorted by whether they ever touched a gateway call.
Show the numbers
ChannelsecretsShare
Never passed the gateway14880%
Came out of a gateway response2715%
Went into a gateway call105%
Total185100%

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 Read and Bash, and offer secure_read and secure_bash as 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:

YOUR MACHINE LEAVES YOUR MACHINE You type a token in the prompt The agent reads .env file or command output You hand a password over password: … or /maisecrets:put maisecrets Detect and swap value → ⟦SECRET_c1⟧ Vault your keychain At execution insert the real value one-time grant, this session only redact the output The model Anthropic · OpenAI sees placeholders only Your tools API · deploy · MCP server · gateway get the real value ⟦SECRET_c1⟧ the agent writes curl -u ⟦SECRET_c1⟧ … real value output
The real value (orange) stays on your machine and goes only into the call that needs it. The model gets placeholders, in both directions.
  • What you type. The prompt stops on your machine. You get it back with a placeholder; paste it, or type /ms to 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 secret or a psql dump 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 @.env mention 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.