Claude Code Credential Masking: Keep Secrets Out of Bash
Claude Code has no built-in credential deny list. Unless you configure one, every sandboxed Bash command can read the same tokens your shell can: GITHUB_TOKEN in the environment, ~/.aws/credentials on disk, the OAuth token inside ~/.config/gh/hosts.yml. The fix is the sandbox.credentials settings block, which since v2.1.187 lets you hide credentials from commands entirely (deny) and, since v2.1.199, lets you mask them: the command sees a placeholder, and the real value is swapped in only on network requests to hosts you name.
This guide covers both modes, the TLS termination requirement that trips up most first attempts at masking, and the settings scopes where the rules actually take effect. Everything here was verified against the official sandboxing and settings documentation on 2026-08-28; version numbers are called out where behavior is gated.
One scope note before the details: all of this applies to the sandboxed Bash tool on macOS, Linux, and WSL2. The sandbox does not run on native Windows, and Claude's own file-reading tools follow a separate permission system (covered in the FAQ).
Start with deny: simple, and it breaks things
A deny entry blocks reads of a file path inside the sandbox and unsets an environment variable before each sandboxed command runs:
{
"sandbox": {
"enabled": true,
"credentials": {
"files": [
{ "path": "~/.aws/credentials", "mode": "deny" },
{ "path": "~/.ssh", "mode": "deny" }
],
"envVars": [
{ "name": "GITHUB_TOKEN", "mode": "deny" },
{ "name": "NPM_TOKEN", "mode": "deny" }
]
}
}
}
deny entries merge from every settings scope the session loads, and a scope can only ever add restrictions, never remove one another scope added. That makes deny the safe default: put it in your user settings (~/.claude/settings.json) and a project cannot widen it.
The cost is that deny removes the credential entirely, which breaks any tool that needs it. With GITHUB_TOKEN unset, gh cannot authenticate; with NPM_TOKEN gone, npm publish fails. If the agent never needs those tools, stop here. If it does, that is what mask is for.
Mask mode: the command sees a sentinel, the server sees the token
With "mode": "mask" (v2.1.199 or later for environment variables), the sandboxed command sees a per-session placeholder called the sentinel instead of the real value. When a request leaves the sandbox for a host listed in the entry's injectHosts, the sandbox proxy replaces the sentinel with the real credential. The command, its logs, and anything the model reads never hold the real token, but requests still authenticate.
{
"sandbox": {
"enabled": true,
"network": {
"tlsTerminate": {},
"allowedDomains": ["*.github.com", "registry.npmjs.org"]
},
"credentials": {
"envVars": [
{ "name": "GH_TOKEN", "mode": "mask", "injectHosts": ["api.github.com"] },
{ "name": "NPM_TOKEN", "mode": "mask" }
]
}
}
}
Two details in that example matter:
GH_TOKENis substituted only on requests toapi.github.com.NPM_TOKENhas noinjectHosts, so it is substituted on requests to every host innetwork.allowedDomains. Naming hosts is the tighter posture.- Every
injectHostsdestination must also be reachable throughnetwork.allowedDomains. The proxy only injects on connections the domain allowlist admits in the first place.
If the same variable appears with deny in any scope, deny wins.
tlsTerminate is not optional
The proxy substitutes the credential inside request headers and bodies, so it has to see them. That requires network.tlsTerminate, an experimental setting (v2.1.199 and later) that makes the built-in sandbox proxy terminate TLS itself instead of passing encrypted traffic through.
Forgetting it is the most common failure, and it fails closed: the command still sees only the sentinel, but the sentinel reaches the server unchanged and authentication fails. Nothing leaks; your tools just stop working, and Claude Code reports the misconfiguration at startup. If gh suddenly gets 401s after you configured masking, check startup output before debugging anything else.
Where mask entries are honored (repo settings are ignored)
A mask rule authorizes the proxy to send your real credential to the listed hosts, so Claude Code only honors mask entries from settings you or your administrator control: user settings, managed settings, and the --settings CLI flag. mask entries in a repository's .claude/settings.json or .claude/settings.local.json are ignored, and so are network.tlsTerminate and credentials.allowPlaintextInject in those files.
This is the right paranoia. Without it, cloning a repository could ship a settings file that routes your GitHub token to a host the repo author picked. Put mask configuration in ~/.claude/settings.json and treat any repo that asks you to add mask entries locally as a red flag.
Verify the mask is active
Ask Claude to run a sandboxed command that would expose the credential:
echo $GH_TOKEN
For a masked environment variable you should see a sentinel value, not your token. For a masked file, cat it: Linux and WSL2 show a sentinel copy, while macOS fails the read entirely (more on that below). /doctor also flags injectHosts entries that can never match their destination.
Structured values: extract and JWT decode
Whole-value replacement suits a bare token. Values with structure need more care, and three optional fields (v2.1.224 or later) handle them:
extract: a regular expression applied across the value, replacing only what capture group 1 matches. ADATABASE_URLconnection string keeps its host and database name readable while the password becomes a sentinel. The pattern must contain at least one capturing group.onExtractNoMatch: what happens when the pattern finds nothing. The defaultwarnpasses the value through unmasked with a warning,denyunsets the variable, anderrorstops sandbox setup. If the secret might be present but your pattern might miss it, usedeny.decode: "jwt": for a variable holding a JSON Web Token. Claude Code verifies the value is a JWT and swaps in a structurally valid fake, so code that decodes the token inside the sandbox keeps working.maskClaimsmasks only named payload claims instead of the whole token. For environment variables,decodecannot be combined withextract.
Masking credential files
File entries accept "mode": "mask" from v2.1.221, and the platform difference matters:
- Linux and WSL2: sandboxed commands read a sentinel copy of the file, and the proxy substitutes the real value on egress.
- macOS: sandboxed commands cannot read the listed file at all. No sentinel copy is built and nothing is substituted, so tools that authenticate with that file do not work inside the sandbox. The effect is
deny, except the read block holds even with filesystem isolation off.
Here is the config for the gh CLI's token, which lives inside a YAML file:
{
"sandbox": {
"enabled": true,
"network": {
"tlsTerminate": {},
"allowedDomains": ["*.github.com"]
},
"credentials": {
"files": [
{
"path": "~/.config/gh/hosts.yml",
"mode": "mask",
"extract": "oauth_token:\\s*(\\S+)",
"injectHosts": ["api.github.com"]
}
]
}
}
}
The extract pattern is what keeps the rest of hosts.yml readable, so gh still parses its own config and only the token is a placeholder. Without extract, the entire file content becomes one sentinel value, which only suits a file holding a single bare secret.
Each mask entry covers a single file, so list credential files individually. Claude Code falls back to deny for anything it cannot mask safely: a directory path, a glob pattern, a file over 8 MiB, or a file that is not UTF-8 text. Write directories as explicit deny entries instead.
AWS keys are a special case
AWS requests are authenticated with a SigV4 signature computed from the credential, not the credential itself, so plain substitution cannot fix them. Mask AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY together: the proxy detects a SigV4 request by the access key's sentinel and re-signs it with the real values. Masking only the secret leaves requests signed with a placeholder the proxy cannot detect, so they fail at AWS; Claude Code warns about that case at startup.
Claude Code links the conventional AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_SESSION_TOKEN variables into one credential automatically when their whole values are masked. Custom variable names need an explicit credentials.awsPairs entry (v2.1.224 or later). Three request forms cannot be re-signed at all (aws-chunked streaming uploads, presigned URLs, and SigV4A signatures); the credentials.sigv4 setting chooses per form whether those fail at the proxy or pass through to be rejected by AWS itself.
Version support at a glance
| Capability | Minimum Claude Code version |
|---|---|
sandbox.credentials block, deny mode | v2.1.187 |
mask for environment variables, network.tlsTerminate | v2.1.199 |
mask for files | v2.1.221 |
extract, decode: "jwt", awsPairs, sigv4 | v2.1.224 |
FAQ
Does Claude Code automatically protect my .env file?
No. There is no built-in credential deny list; only the files and variables you configure are restricted, and the sandbox.credentials setting governs sandboxed Bash commands only. Claude's own file tools are a separate path: to stop the model from reading secrets directly, add a Read deny rule to your permissions, such as Read(.env), which matches a .env file at any depth under the current directory. We set both: a permissions deny rule for the model's reads and a credentials entry for Bash.
Why did gh or npm stop authenticating after I set up masking?
Three usual causes, in order: network.tlsTerminate is missing, so the sentinel reaches the server unmasked and auth fails (check startup output for the misconfiguration report); the target host is in injectHosts but not covered by network.allowedDomains, so the proxy never admits the connection; or the same variable also has a deny entry in some scope, and deny takes precedence over mask.
Does credential masking work on Windows?
Not on native Windows, because the Bash sandbox itself only runs on macOS, Linux, and WSL2. Inside a WSL2 distribution, masking behaves like Linux, including sentinel copies of masked files.
Is deny redundant with CLAUDE_CODE_SUBPROCESS_ENV_SCRUB?
They solve different problems. credentials.envVars entries apply to sandboxed Bash commands. CLAUDE_CODE_SUBPROCESS_ENV_SCRUB strips Anthropic and cloud provider credentials from all subprocesses regardless of sandboxing, but only that fixed set. Use the env var as a floor and credentials entries for the tokens specific to your setup.