Here's the short version, up top. Claude Code's sandbox can hand a command a secret it never actually sees: the command reads a sentinel decoy, and the egress proxy swaps the real value back only on requests to hosts you allow. That works cleanly for a bare token. It quietly breaks the moment the value has structure a tool reads — a JWT your code decodes, an AWS key the SDK signs with, a DATABASE_URL a driver parses. Whole-value masking hands those tools a decoy they can't work with, and you either get a mystery failure or you fall back to deny and lose the tool entirely.
Claude Code v2.1.224 (August 2026) closes that gap with three fields under sandbox.credentials.envVars[]: extract masks only a captured span, decode: "jwt" swaps in a structurally valid decoy token, and awsPairs lets the proxy re-sign SigV4 requests. All three still require network.tlsTerminate, and none of them can be turned on from a checked-out repo. Below is the exact config for each of the three real cases.
If you want the mechanism behind masking itself — the sentinel, the proxy, the deny-vs-mask decision — start with Claude Code 2.1.221 masks credential files. This piece is the next cut: the three values that plain masking can't handle.
The baseline: a bare token#
Nothing structured here, so plain masking is right. The sandboxed command sees a per-session sentinel; the proxy substitutes the real token on egress to the hosts in injectHosts (each of which must also be in network.allowedDomains).
{
"sandbox": {
"network": {
"tlsTerminate": {},
"allowedDomains": ["*.github.com", "registry.npmjs.org"]
},
"credentials": {
"envVars": [
{ "name": "GH_TOKEN", "mode": "mask", "injectHosts": ["api.github.com"] },
{ "name": "NPM_TOKEN", "mode": "mask" }
]
}
}
}
GH_TOKEN is injected only on requests to api.github.com; NPM_TOKEN has no injectHosts, so it's substituted on requests to every host in allowedDomains. gh and npm authenticate normally, and the token never touches the command's environment, its stdout, or anything a prompt injection makes it print. That's the whole win — and everything below is about keeping that win when the value isn't a bare token.
Case 1 — a JWT your code decodes: decode: "jwt"#
Point plain masking at a Supabase service key, a Clerk session token, or any JWT, and the first line of code that base64-decodes the payload throws — the decoy isn't a real token. decode: "jwt" verifies the value is a JWT and replaces it with a structurally valid fake token, so decoding, reading exp, and checking claims all keep working inside the sandbox; the proxy swaps the real token in on egress.
{
"sandbox": {
"network": {
"tlsTerminate": {},
"allowedDomains": ["*.supabase.co"]
},
"credentials": {
"envVars": [
{
"name": "SUPABASE_SERVICE_KEY",
"mode": "mask",
"decode": "jwt",
"maskClaims": ["sub", "email"],
"injectHosts": ["api.supabase.co"]
}
]
}
}
}
maskClaims is the precise tool: it masks only the listed top-level payload claims and leaves the rest readable, so code that branches on role or iss still sees the real values while sub and email stay hidden. If the value doesn't verify as a JWT — or no listed claim matches — Claude Code passes it through unmasked with a warning rather than corrupting it, so a misconfigured entry fails loud, not silent. One constraint: decode can't be combined with extract.
Case 2 — AWS keys the SDK signs with: awsPairs#
This is the one that bites people. AWS SigV4 signs a hash of the request contents, so if AWS_SECRET_ACCESS_KEY is a sentinel, the signature is computed from the decoy and every AWS call comes back 403. Masking the secret alone doesn't just fail to protect you — it breaks the tool.
The fix is to mask the access key ID and the secret together so the proxy can detect the SigV4 request by the key's sentinel and re-sign it with the real pair after substitution. If you use the conventional variable names, Claude Code links them automatically:
{
"sandbox": {
"network": {
"tlsTerminate": {},
"allowedDomains": ["*.amazonaws.com"]
},
"credentials": {
"envVars": [
{ "name": "AWS_ACCESS_KEY_ID", "mode": "mask", "injectHosts": ["sts.amazonaws.com", "s3.amazonaws.com"] },
{ "name": "AWS_SECRET_ACCESS_KEY", "mode": "mask" },
{ "name": "AWS_SESSION_TOKEN", "mode": "mask" }
]
}
}
}
If your credential lives in non-standard variable names, group them yourself with awsPairs:
{
"sandbox": {
"credentials": {
"envVars": [
{ "name": "MY_KEY_ID", "mode": "mask", "injectHosts": ["sts.amazonaws.com"] },
{ "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 whole value — no extract, no decode. The proxy re-signs on the hosts listed in the access-key entry's injectHosts. Three AWS request forms carry signatures the proxy can't recompute (a presigned URL is the classic one); for those, the sigv4 setting lets you set a form to passthrough, which forwards the placeholder-derived signature so the calling tool receives AWS's own rejection instead of an opaque proxy error. Claude Code warns at startup if you mask the secret without its access key ID — the exact 403 trap this field exists to prevent.
Case 3 — a value with one secret span: extract#
A DATABASE_URL, a .npmrc auth line, a Redis URL — the tool needs to parse the value (scheme, host, port, database) but the only secret is the password. Whole-value masking replaces the structure too, and the parser chokes. extract is a regex whose group 1 is the secret; Claude Code masks only that captured span and leaves the rest byte-for-byte real.
{
"sandbox": {
"credentials": {
"envVars": [
{
"name": "DATABASE_URL",
"mode": "mask",
"extract": "://[^:]+:([^@]+)@",
"onExtractNoMatch": "error",
"injectHosts": ["db.internal.example.com"]
}
]
}
}
}
The pattern captures the password between the credentials colon and the @, so postgres://app:<sentinel>@db.internal.example.com:5432/prod still parses — driver reads the host and port, agent never holds the password, proxy substitutes it on egress. Set onExtractNoMatch deliberately: warn (the default) passes the value through unmasked if the pattern misses, which is the wrong default for a production secret — use error to stop sandbox setup until the pattern is right, or deny to unset the variable rather than leak it. The pattern must contain at least one capturing group.
The two rules that apply to all three#
network.tlsTerminate is mandatory. The proxy can only substitute a credential it can read, which means it has to terminate TLS itself. Turn masking on without it and it fails closed — the sentinel reaches the server unchanged, auth fails, and Claude Code reports the misconfiguration at startup instead of leaving you a mystery 401. The setting is experimental and needs v2.1.199 or later.
A repo can't mask for itself. Because mask, awsPairs, and sigv4 authorize the proxy to send a real credential to a host, they're honored only from user settings, managed settings, and the --settings flag — never a checked-out .claude/settings.json. A cloned repo can't redirect your token, and deny always wins over mask for the same secret, so an administrator can pin a credential shut no matter what a project asks for.
Masking is one layer, not the whole defense — it pairs with a tight allowedDomains allowlist and short-lived, narrowly scoped credentials, and it's the layer that lets an unattended agent hold no plaintext. For where it sits in a broader posture, see secrets management for AI agents, why the approval prompt is not a security boundary, and how to contain a coding agent's shell before it can run an RCE.



