How safekeys handles secrets in Claude Code
A Claude Code mod that hides secret values and checks where bound keys are sent.
I use Claude Code all day, and some tasks need an API key or a password. I kept having to paste secrets into .env files or into the chat so Claude could use them. Once a key is in the prompt, it can also end up in the conversation transcript and prompt history.
I built safekeys so I could give Claude a reference to a secret without putting the value in its context. Later I realised that keeping the value hidden was only part of the problem. A page Claude reads could contain instructions to send a key somewhere else. If Claude followed those instructions, it could use the reference even without seeing the value.
Storing a key
In a normal prompt I can write:
$:API_KEY=example_secret call the API
For a token in that format, the prompt hook stores the value in the macOS Keychain or a Linux Secret Service keyring before the prompt is saved or sent. It replaces the token with $API_KEY. When a Bash command refers to that name, safekeys sources a helper that loads the value from the keychain at runtime. The value is still visible in the input box until I press Enter.
safekeys is a Claude Code mod. Claude Code loads its JavaScript module, register.js, into the session, where it subscribes to events such as prompt.submit. A handler can change an event before passing it on, or stop it. That is how safekeys rewrites a prompt before it is saved. The module also stays loaded for the whole session, so it can keep the values in memory to mask output.
Three hooks cover the main paths:
prompt.submitstores a new value and rewrites the prompt.tool.callchecks tool inputs and masks stored values in tool results. It also loads referenced keys into Bash commands.prompt.mentionrefuses an@-mentioned file containing a stored value. A mention attaches the file without a tool call, so the output mask would not see it.
Masking has grown beyond exact matches. It checks several encoded and escaped forms and pieces of longer keys. Short values are tricky: a password like root could also be a username. Values under 12 characters are matched as whole words and do not block a tool call.
Limiting destinations
For a key that should only reach one API, I can add a binding when I save it:
$:[email protected]=example_secret
I can also add or change a binding later with $:[email protected], without retyping the value. A binding can list several hosts, a wildcard domain, an SSH host or alias, local, or any. SSH wildcards are supported too, such as ssh:*.example.com.
When a Bash command references a bound key, the hook looks for destinations visible in the command. These include URLs, SSH targets and bare hosts supplied to network tools such as curl. If a visible destination other than localhost is outside the key's binding, safekeys asks before running the command. If I decline, or it cannot ask me, the command is refused. If I allow it, the usual Claude Code permission checks still apply.
I had to narrow this check after it started interrupting ordinary work. An SSH command containing mysql.user, for example, was treated as if that dotted name were another host. The check now handles the SSH target separately. It also no longer asks about localhost, or about dotted words that are not hosts.
The binding is a guard rail, not a firewall. A destination hidden in a variable or inside a script is not visible to the check. Commands running on an allowed SSH host are not inspected for their eventual destination. Unbound keys are not checked, and /safekeys bindings off disables destination questions while preserving the bindings. The project is intended to prevent accidental leaks, not stop someone who already controls the session.
When a hook fails
Claude Code can skip a hook that crashes, so the failure path matters. If the prompt hook cannot store a secret before passing the prompt on, safekeys drops the prompt rather than send the raw value. I have to type it again after fixing the keychain problem.
The tool hook has a different fallback. If its check fails before the tool runs, it runs the tool and masks the result using the last successfully loaded secret list. If the tool has already run and the result cannot be checked, it withholds the result. Those paths have different guarantees, so I do not treat every hook failure as the same kind of fail-closed protection.
The hook I removed
I also tried masking prompt attachments such as file-change reminders. In a managed environment, a security plugin prevented my hook from running, and an alternative hook was skipped too. I removed the attachment hook in 0.5.2, so safekeys no longer masks those reminders. The tool-output and mention checks remain.
Try it
In Claude Code 2.1.287 or newer:
/plugin marketplace add pravnyadv/claude-plugins
/plugin install safekeys@pravnyadv
/reload-plugins
Linux also needs secret-tool and a running keyring. The code and setup details are in claude-safekeys.