Skip to main content

Sandbox Credential Masking: keep tokens working, keep them secret

Advanced
What you'll learn
  • 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 varRemoved from the sandbox environmentSet to a per-session sentinel
FileRead failsSandbox sees a sentinel copy (Linux/WSL2) or read fails (macOS)
Tool that needs the secretBreaks (gh, npm, aws fail without a token)Still works — proxy swaps in the real value on the wire
Real value ever in the sandbox?NeverNever (sentinel only; proxy holds the real value)
Requires tlsTerminateNoYes — proxy has to see request contents to substitute
Honored from repo settings?YesNo — 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" }
      ]
    }
  }
}
  • injectHosts scopes substitution to specific hosts. GH_TOKEN will never reach anything but api.github.com.
  • Omit injectHosts and the real value is substituted on requests to every host in network.allowedDomains. Fine for NPM_TOKEN where 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.
  • onExtractNoMatch controls what happens when the pattern matches nothing: warn (default — pass through unmasked with a warning), deny (fail closed), or error (fail the sandbox). Use deny when 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 needs iss or aud won't care, but code that reads sub gets a fake value.
  • With maskClaims, the other claims stay readable; only the ones you list are replaced individually. Useful when the app needs iat/exp/iss for routing but must never see sub/email.
  • decode cannot be combined with extract on 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 mask entry that masks its entire value — no extract, no decode.
  • sessionTokenVar is optional; when set, the proxy sends the real token as x-amz-security-token on 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 extract pattern is what keeps the rest of hosts.yml readable. 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 optional maskClaims) to keep the token shape decodable inside the sandbox.
  • maskDuplicates: true also 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. mask falls back to deny for a directory path, a glob pattern, a file larger than 8 MiB, or a file that isn't UTF-8 text.

OS matrix

FeatureLinuxWSL2macOS
Env var maskYesYesYes
File mask — sentinel copyYesYesNo (falls back to deny)
extract / decode / maskClaims for filesYesYesOnly 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.tlsTerminate
  • credentials.allowPlaintextInject (lets the proxy inject into unencrypted requests)
  • awsPairs
  • sigv4

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

Watch out
  • 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.

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_TOKEN and NPM_TOKEN are masked and scoped with injectHosts.
  • The conventional AWS trio is masked; Claude Code auto-links them for SigV4 re-signing, no awsPairs needed.
  • 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/credentials and ~/.ssh are deny-listed as directories (which is why they're deny, not mask — masking doesn't handle directories).
  • Belongs in your user settings.json, not the repo.

Check yourself

0/3
  1. Your team ships a .claude/settings.json in the repo with mask entries for GH_TOKEN. Teammates clone and run Claude Code. What happens?
  2. You mask AWS_SECRET_ACCESS_KEY but not AWS_ACCESS_KEY_ID. What breaks?
  3. You add a mask entry but forget network.tlsTerminate. What actually happens at runtime?

Next