If you run a Claude Managed Agent that has to call a third-party API, the old question was where do I put the key so the agent can use it but not leak it? The 2026 answer is: you don't put it anywhere the agent can reach. You register the secret in a vault, the sandbox gets an opaque placeholder, and the real value is swapped in at the network edge — outbound only, and only for hosts you've named.
The one line to remember: the agent authenticates with a key it never sees, because substitution happens at egress, not in the sandbox. That is the whole security argument. A prompt injection can talk your agent into printing every environment variable it holds and still leak nothing but a placeholder string.
The model: vault per user, agent per product#
A Managed Agent is a persisted, versioned resource — your product. A session is one run on behalf of one end user. A vault is the collection of credentials for that user, and you attach it with vault_ids when you create the session:
session = client.beta.sessions.create(
agent=agent.id,
environment_id=environment.id,
vault_ids=[vault.id], # Alice's secrets, this session only
title="Alice's Notion sync",
)
Register a credential once, reference it by ID forever after. No secret store of your own, no token riding along on every call. One caveat worth internalizing: vaults are workspace-scoped, so anyone with an API key in the same workspace can reference them. Keep one workspace per trust boundary, and revoke by archiving or deleting the vault.
Environment-variable credentials: the placeholder trick#
There are two credential families. MCP credentials (mcp_oauth, static_bearer) are keyed by mcp_server_url and injected when the agent connects to that server. The one that changes how you think about secrets is environment_variable — for anything that authenticates through an env var: a CLI, an SDK, a plain requests call.
cred = client.beta.vaults.credentials.create(
vault_id=vault.id,
display_name="Notion API key for sandbox",
auth={
"type": "environment_variable",
"secret_name": "NOTION_API_KEY",
"secret_value": "ntn_your-secret-here",
"networking": {"type": "limited", "allowed_hosts": ["api.notion.com"]},
"injection_location": {"header": True},
},
)
Inside the sandbox, NOTION_API_KEY resolves to an opaque placeholder. Your code reads it and sets a header exactly as usual. On the way out — after the request leaves the sandbox, before it reaches api.notion.com — the platform substitutes the real key. The secret_value is write-only: it is never returned by any API response.
Two independent scopes are doing work here, and it's worth keeping them straight:
networking.allowed_hostsscopes which hosts the secret is substituted for. Use{"type": "limited", "allowed_hosts": [...]}; reserveunrestrictedfor callers whose domains you genuinely can't enumerate. This does not replace the environment's own network policy — both the credential and the environment must allow a host before a substituted request goes through.injection_locationscopes which part of the request the secret lands in.
injection_location: header, body, and the 400s#
injection_location is an optional object with two booleans, header and body. The defaults are asymmetric between create and update, and that asymmetry is the part people get wrong:
| Operation | Behavior |
|---|---|
| Create | Any field you include defaults the rest to false. {"header": true} is header-only. Omit the object entirely and both locations are enabled. |
| Update | Fields merge individually. {"body": false} disables body, leaves header as it was. |
Two hard rules: a credential must keep at least one location enabled — a create or update that would turn both off returns a 400 — and passing an explicit null (for the object or a field) is also a 400, with the API telling you to omit the field instead.
Which to pick? Request bodies are frequently assembled from whatever content the agent is handling, so the body is the wider exposure surface. Most services read an API key from a header. So {"header": true} is the tighter, boring, correct default — enable body only for the specific API that demands the key in its payload.
The safest configuration is the narrowest one that still authenticates: header-only, one allowed host, least-privilege key. injection_location exists so "the key can be substituted here but nowhere else" is a setting, not a hope.
What egress substitution breaks#
This is the honest catch, and it decides whether the feature fits your stack at all. Because the real secret only appears outside the sandbox, anything that touches the value inside the sandbox sees the placeholder:
- Format validators. A client that checks the key's shape at startup may reject the placeholder before it ever makes a call.
- Request signers. Anything that computes a signature from the secret — AWS SigV4 is the textbook case — signs the placeholder and produces an invalid signature. Signed-request APIs are out.
- Token exchanges. Substitution is outbound only. If your client trades the secret for a session token (an OAuth client-credentials grant), the token comes back into the sandbox unredacted. Do the exchange yourself and store the resulting token in the vault.
The rule of thumb: env-var credentials work when the client sends the secret verbatim in a header or body, and not otherwise. A stray placeholder string arriving at the third party is the tell that either the location is disabled or the host isn't in allowed_hosts.
Rotation and the webhooks that page you#
Credentials are re-resolved periodically during a live session, so rotating, archiving, or revoking one propagates without a restart — an injection_location change lands the same way. The failure you must not miss is a credential going dead mid-run. Subscribe to the vault/credential webhooks:
vault_credential.refresh_failed— anmcp_oauthtoken can't be refreshed (dead refresh token or an unrecoverable OAuth error).vault_credential.archived/vault_credential.deleted— teardown, including the cascade when a whole vault is archived or deleted (vault.archived/vault.deletedfire too).
When a refresh fails, POST /v1/vaults/{id}/credentials/{cred}/mcp_oauth_validate returns a status that tells you what to do: valid (no action), invalid (grant is gone — prompt the user to re-authorize), or unknown (transient — wait and retry). Without the webhook, the first sign of a revoked key is a session quietly failing every call.
The founder takeaway#
For a solo builder shipping an agent that acts on customer accounts, this collapses a whole category of secret-plumbing you'd otherwise own: no per-tenant vault server, no key on the wire each call, and — the part that matters after the first security review — a real architectural answer to prompt injection instead of a prompt that begs the model not to leak. Store the key least-privilege, list one allowed host, keep injection_location header-only, and wire the refresh_failed webhook to your pager. The agent does the work; it just never holds the key. For the per-session config side of the same product — swapping the model or tools without minting a new agent version — see overriding a Claude agent for one session.



