Sandbox Credential Masking: keep tokens working, keep them secret
- When to prefer mask over deny — and when deny is still safer
- The four masking modes: whole-value, extract, decode: "jwt" with maskClaims, and awsPairs for SigV4
- The exact JSON to mask env vars, JWTs, AWS credentials, and files like ~/.config/gh/hosts.yml
- Why tlsTerminate is mandatory — and the silent-fail pattern when it's missing
- The settings-source rule: why mask entries are ignored from .claude/settings.json
- The Linux/WSL vs macOS split: file masking degrades to deny on macOS
Most secret leaks don't happen because a token was stolen — they happen because a well-meaning script printed one to a log, a diff, or a subagent's transcript. Sandbox credential masking is Claude Code's built-in fix: sandboxed commands see a per-session sentinel value instead of the real secret, and the sandbox proxy substitutes the real value on outbound requests to hosts you allow. The command still authenticates. The command and anything it logs never hold the real credential.
This page is the practitioner's guide: the four masking modes, the exact JSON, the gotchas, and the OS matrix.
Mask vs deny: which do you want?
Two ways to keep a credential out of a sandboxed command's hands. They look similar. They are not.
"mode": "deny" | "mode": "mask" | |
|---|---|---|
| Env var | Removed from the sandbox environment | Set to a per-session sentinel |
| File | Read fails | Sandbox sees a sentinel copy (Linux/WSL2) or read fails (macOS) |
| Tool that needs the secret | Breaks (gh, npm, aws fail without a token) | Still works — proxy swaps in the real value on the wire |
| Real value ever in the sandbox? | Never | Never (sentinel only; proxy holds the real value) |
Requires tlsTerminate | No | Yes — proxy has to see request contents to substitute |
| Honored from repo settings? | Yes | No — user, managed, or --settings only |
Rule of thumb. Use deny when the tool doesn't need the credential and you want it gone. Use mask when the tool needs to authenticate — you want gh pr view to work without letting the transcript, a subagent, or an errant env dump ever hold the real GH_TOKEN.
Prerequisites: tlsTerminate
mask works by substituting the sentinel with the real value inside outbound HTTP request headers and bodies. The sandbox proxy has to see those bytes, so network.tlsTerminate is mandatory. Without it, masking fails in the worst way: the command still sees only the sentinel, the sentinel reaches the server unchanged, and authentication fails. Claude Code reports this misconfiguration at startup — read the warnings.
{
"sandbox": {
"network": {
"tlsTerminate": {},
"allowedDomains": ["api.github.com", "registry.npmjs.org"]
}
}
}
Every injectHosts entry you use later must also appear in network.allowedDomains. If a host isn't allowed, the proxy never sees the request to substitute.
Environment variable masking
The base case. One envVars entry per credential.
Mask GH_TOKEN and NPM_TOKEN
{
"sandbox": {
"network": {
"tlsTerminate": {},
"allowedDomains": ["api.github.com", "registry.npmjs.org"]
},
"credentials": {
"envVars": [
{ "name": "GH_TOKEN", "mode": "mask", "injectHosts": ["api.github.com"] },
{ "name": "NPM_TOKEN", "mode": "mask" }
]
}
}
}injectHostsscopes substitution to specific hosts.GH_TOKENwill never reach anything butapi.github.com.- Omit
injectHostsand the real value is substituted on requests to every host innetwork.allowedDomains. Fine forNPM_TOKENwhere the token is scoped to the registry. - To confirm the mask is live: ask Claude to run
echo "$GH_TOKEN"in a sandboxed command. Output should be a per-session sentinel, not the real token.
Extract: mask one field inside a structured value
Many "credentials" aren't a bare secret — they're a connection string with a password buried inside. extract masks only the capture group of your regex, leaving the rest readable so parsers keep working.
Mask the password inside DATABASE_URL, keep the rest parseable
{
"name": "DATABASE_URL",
"mode": "mask",
"extract": "://[^:]+:([^@]+)@"
}- The pattern must contain at least one capturing group; only group 1's text gets replaced.
onExtractNoMatchcontrols what happens when the pattern matches nothing:warn(default — pass through unmasked with a warning),deny(fail closed), orerror(fail the sandbox). Usedenywhen the secret should always be present.
JWT masking with decode and maskClaims
For access tokens shaped like a JWT (header.payload.signature), whole-value masking breaks any code inside the sandbox that decodes the token to look at claims. decode: "jwt" fixes it: Claude Code verifies the value is a valid JWT and swaps in a structurally valid fake token, so jwt.decode(...) inside the sandbox still returns a well-formed payload.
Mask a session JWT but keep the shape decodable
{
"name": "SESSION_JWT",
"mode": "mask",
"decode": "jwt",
"maskClaims": ["sub", "email"]
}- Without
maskClaims, the entire fake token replaces the real one — code that only needsissoraudwon't care, but code that readssubgets a fake value. - With
maskClaims, the other claims stay readable; only the ones you list are replaced individually. Useful when the app needsiat/exp/issfor routing but must never seesub/email. decodecannot be combined withextracton the same entry. Pick one.- If the value doesn't verify as a JWT (or no listed claim matches), Claude Code passes it through unmasked with a warning. Use
onExtractNoMatch: "deny"to fail closed.
Requires Claude Code v2.1.224 or later.
AWS SigV4: mask keys together with awsPairs
AWS is the tricky case. SigV4 requests carry an HMAC signature over the request contents, computed from the secret key. If you mask the secret but not the access key ID, the proxy has no way to detect which request is AWS — the request goes out signed with the sentinel, AWS rejects it, and you get confusing failures. Always mask the access key ID and the secret together.
The good news: for the conventional variable names AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN, Claude Code links them automatically when all three are whole-value mask entries. The proxy detects a SigV4 request by the access key's sentinel and re-signs it after substituting the real values.
If your AWS credentials live in non-conventional variable names, group them yourself with awsPairs.
Group non-standard AWS variables for SigV4 re-signing
{
"sandbox": {
"credentials": {
"envVars": [
{ "name": "MY_KEY_ID", "mode": "mask" },
{ "name": "MY_SECRET_KEY", "mode": "mask" },
{ "name": "MY_SESSION_TOKEN", "mode": "mask" }
],
"awsPairs": [
{
"accessKeyIdVar": "MY_KEY_ID",
"secretAccessKeyVar": "MY_SECRET_KEY",
"sessionTokenVar": "MY_SESSION_TOKEN"
}
]
}
}
}- Each named variable must be a
maskentry that masks its entire value — noextract, nodecode. sessionTokenVaris optional; when set, the proxy sends the real token asx-amz-security-tokenon re-signed requests.- Requires Claude Code v2.1.224 or later.
When the proxy can't re-sign: credentials.sigv4
Three AWS request forms carry signatures the proxy can't recompute — chunked payload signing, presigned URLs, and SigV4A asymmetric signatures. By default the proxy fails these rather than forward a broken signature. If a specific tool relies on one of them and you'd rather see AWS's own rejection than a proxy error, relax that form with credentials.sigv4:
{
"sandbox": {
"credentials": {
"sigv4": {
"presignedUrl": "passthrough",
"chunkedPayload": "passthrough",
"sigv4a": "passthrough"
}
}
}
}
Setting a form to passthrough forwards the placeholder-signed request unchanged so the calling tool receives AWS's response. Only affects requests signed with a masked pair's placeholder — requests signed with unmasked credentials are never touched. Also v2.1.224+ and settings-source restricted.
File masking: mask an on-disk credential
Some tools store their token in a config file, not an env var (gh in ~/.config/gh/hosts.yml, docker in ~/.docker/config.json, an SDK's ~/.netrc). File masking gives the sandboxed process a sentinel copy of the file on Linux and WSL2. On macOS, file masking falls back to deny — the file is unreadable inside the sandbox.
Mask the oauth_token line inside ~/.config/gh/hosts.yml
{
"sandbox": {
"network": {
"tlsTerminate": {},
"allowedDomains": ["api.github.com"]
},
"credentials": {
"files": [
{
"path": "~/.config/gh/hosts.yml",
"mode": "mask",
"extract": "oauth_token:\\s*(\\S+)",
"injectHosts": ["api.github.com"]
}
]
}
}
}- The
extractpattern is what keeps the rest ofhosts.ymlreadable. Without it, Claude Code replaces the entire file content with one sentinel — fine for a file that holds a bare secret and nothing else, but breaks any parser expecting structure. - For a file holding a JWT, add
decode: "jwt"(with optionalmaskClaims) to keep the token shape decodable inside the sandbox. maskDuplicates: truealso replaces verbatim copies of the masked value found outside the matched spans. Reserve for long, high-entropy secrets — a short value would get replaced everywhere it appears.- List each credential file individually.
maskfalls back todenyfor a directory path, a glob pattern, a file larger than 8 MiB, or a file that isn't UTF-8 text.
OS matrix
| Feature | Linux | WSL2 | macOS |
|---|---|---|---|
Env var mask | Yes | Yes | Yes |
File mask — sentinel copy | Yes | Yes | No (falls back to deny) |
extract / decode / maskClaims for files | Yes | Yes | Only when filesystem isolation is off |
On macOS, mask file entries are applied as deny before the pattern runs whenever filesystem isolation is on. To get the extract/decode behavior on macOS, you must disable filesystem isolation — which is a bigger tradeoff than most teams want to make.
The settings-source rule (this trips everyone)
mask entries authorize the sandbox proxy to send your real credential to the hosts you list. That's a delegation of trust. Claude Code enforces this by only honoring the following keys from settings scopes you or your administrator control — user settings, managed settings, or --settings CLI flag. They are silently ignored from a repository's .claude/settings.json or .claude/settings.local.json:
mode: "mask"entries (env vars and files)network.tlsTerminatecredentials.allowPlaintextInject(lets the proxy inject into unencrypted requests)awsPairssigv4
Practical impact. You cannot ship a .claude/settings.json in a shared repo that turns on masking for teammates. Each teammate has to put the mask entries in their own user settings, or an admin has to push them via managed settings. This is by design — a repo you cloned shouldn't be able to command the sandbox to email your GH_TOKEN to evil.example.com.
Common gotchas
- No tlsTerminate → mask fails silently. Sandbox sees sentinel; sentinel goes to the server; auth fails. Check startup warnings.
- injectHosts must appear in network.allowedDomains, or the proxy never sees the request to substitute.
- AWS: masking only the secret (not the access key ID) means the proxy can't detect the request. Mask both together, or use awsPairs.
- Repo-level .claude/settings.json is IGNORED for mask/tlsTerminate/awsPairs/sigv4. Put them in user or managed settings.
- File mask on macOS becomes deny. If your app needs to read the file, either disable filesystem isolation or run on Linux/WSL2.
- extract without a capturing group is a config error — the pattern must contain group 1.
- decode: "jwt" and extract cannot be combined on the same entry — pick one.
- File mask falls back to deny for: directory paths, glob patterns, files > 8 MiB, or non-UTF-8 files. Break directories into per-file entries.
Recommended configuration
A reasonable "belt and braces" starting point for a developer laptop that runs Claude Code sessions against GitHub, npm, and AWS:
{
"sandbox": {
"network": {
"tlsTerminate": {},
"allowedDomains": [
"api.github.com",
"registry.npmjs.org",
"*.amazonaws.com"
]
},
"credentials": {
"envVars": [
{ "name": "GH_TOKEN", "mode": "mask", "injectHosts": ["api.github.com"] },
{ "name": "NPM_TOKEN", "mode": "mask", "injectHosts": ["registry.npmjs.org"] },
{ "name": "AWS_ACCESS_KEY_ID", "mode": "mask" },
{ "name": "AWS_SECRET_ACCESS_KEY", "mode": "mask" },
{ "name": "AWS_SESSION_TOKEN", "mode": "mask" },
{ "name": "ANTHROPIC_API_KEY", "mode": "deny" },
{ "name": "OPENAI_API_KEY", "mode": "deny" }
],
"files": [
{ "path": "~/.aws/credentials", "mode": "deny" },
{ "path": "~/.ssh", "mode": "deny" }
]
}
}
}
Notes on the shape:
GH_TOKENandNPM_TOKENare masked and scoped withinjectHosts.- The conventional AWS trio is masked; Claude Code auto-links them for SigV4 re-signing, no
awsPairsneeded. - LLM API keys are
deny-ed: no sandboxed process should ever need them, and if you left them accessible a runaway subagent could burn your budget. ~/.aws/credentialsand~/.ssharedeny-listed as directories (which is why they'redeny, notmask— masking doesn't handle directories).- Belongs in your user
settings.json, not the repo.