Secrets management for agents: the model must never be able to read the secret it uses.
A web app keeps its Stripe key in an environment variable and sleeps fine, because nothing inside the process can be talked into printing it. An agent breaks that assumption: the loop reads attacker-influenced text on every step, so any secret reachable from inside the loop is one crafted instruction away from your logs, a tool argument, or an exfiltration URL. The fix is not a better vault — it is moving the credential out of the model's reach entirely, so the agent calls tools by name and a broker attaches the secret at the last hop, where no prompt can see it.
Why the ordinary answer stops working.
For a conventional service, "load the secret from an environment variable or a secrets manager, use it, never log it" is enough, because the code paths that touch the secret are fixed and auditable. An agent voids the premise that the code is the only thing deciding what happens. The model reads its context — system prompt, tool results, retrieved documents, web pages — and any of it can carry an instruction. If the secret is anywhere the model or its tool code can pick it up and put it into a string, prompt injection can turn "use the key" into "print the key."
The failures are mundane, which is why they keep happening:
- The key passed as a tool argument. A
call_api(url, api_key)tool puts the secret into the model's context the moment the model has to fill in the argument — and now it is in the transcript, the trace, and every retry. - The key pasted into the system prompt. "Your API key is sk-…, use it for the billing tool." Fatal and surprisingly common; the secret is now the most quoted string in your prompt cache.
- The key in a tool error. An HTTP client raises
401 Unauthorized: bad token sk-live-…and the tool returns the exception verbatim into the loop. The model never had the key until the error handed it over. - The key in a log line. Debug logging that dumps the outbound request, joined to a trace the model can later retrieve, closes the loop.
The test that names the problem: search your traces, tool-result store, and prompt-cache for the first six characters of a live secret. If it appears anywhere the model can read on a later step, the secret is already compromised — you are one injection away from it leaving the building.
The one rule: reference, never value.
The whole discipline collapses to a single invariant. The model handles the name of a capability; the runtime handles the secret behind it. The agent decides to send an email; it does not decide with which token. The tool call carries a logical target and business arguments — send_email(to, subject, body) — and the credential that authorizes it is attached below the model, at the point where the request actually leaves your infrastructure.
This is the same move as returning tool results by reference instead of by value, applied to authority. It cleanly separates two decisions people wrongly fuse:
- Which action to take — the model's job, made from context, and therefore corruptible by context.
- Which credential authorizes it — the platform's job, made from policy, and therefore outside the reach of anything the model reads.
Once the secret is never a value the model can name, the entire class of "the model leaked the key" bugs is gone by construction, not by vigilance. It is the confused-deputy problem solved in the safe direction: authority is exercised through the runtime, never held in the context, which is the deeper story in agent-identity-and-permissions.
The credential broker, concretely.
Making "reference, not value" real needs a component between your tool executor and the outside world that owns the secrets and injects them at egress. Three layers do the work:
- A store the model cannot address. Vault, a cloud secret manager, or a sealed sidecar — fetched by the tool runtime at call time, never baked into the image, the prompt, or a config file the retrieval tool can read. Prefer dynamic secrets with minute-scale TTLs, so a leaked credential expires before it is useful and rotation is the absence of renewal rather than a redeploy (the lifetime argument is the same one made in scoped-credentials-for-agents).
- An injection point at the boundary. An egress proxy or a thin request-signing shim that receives the logical call, looks up the credential for that target and this agent's identity, attaches the
Authorizationheader, and forwards it. The secret exists on the wire for exactly one hop and never travels back up into the loop. This is the natural companion to egress-control-for-agents: the same chokepoint that decides where a call may go is where you decide with what authority. - A redaction pass on everything that returns. Responses, errors, and headers are filtered before they re-enter the context. A
401becomes "auth failed for target billing-api," never the token that failed. Tie this to rendering-agent-output-safely — the same boundary that sanitises output for the user sanitises tool results for the model.
MCP servers are where teams most often re-introduce the value pattern by accident, wiring a provider key into the server's environment and then echoing upstream errors straight back to the client. The broker belongs on the far side of the server, and the errors must be scrubbed before the MCP boundary — see mcp-security-anti-patterns.
Rotation, and the shortcuts that quietly undo it.
Rotation is where secrets management earns its keep, and where the broker pays off: because the model never held the secret, rotating it touches one store and zero prompts. Rotate on a schedule short enough that a missed leak self-heals, and rotate immediately on suspicion — a trace that shows a secret where it should not be is an incident, not a cleanup ticket. Four shortcuts look like they preserve the invariant and do not:
- "The tool code reads the env var, the model never sees it." True only until the tool puts the value into a return, an error, or a log the model later retrieves. The env var is fine; the discipline is what returns to the loop, and that is exactly what deadlines erode.
- "We redact secrets in logs with a regex." A denylist of known key shapes misses the next provider's format and any secret that was reformatted in transit. Redact by allowlisting what may enter the context, not by blocklisting what may not.
- "One service account key for all the agent's tools." Convenient, and it makes every leak maximal and every rotation a fleet-wide event. Per-target credentials keep the blast radius and the rotation radius the same size.
- "Short-lived tokens, refreshed from a long-lived refresh token the agent holds." The refresh token is now the real long-lived secret sitting inside the loop. If the agent can refresh, an injection can refresh; move the refresh below the model too.
Auditing custody, not just access.
The audit question for a conventional system is "who accessed this secret." For an agent the sharper question is "could the model ever have named it," and that is answered by inspecting custody, not the access log. Two properties make custody verifiable:
- Every credential attachment is logged where the model cannot reach it. The broker records which secret it attached, for which target, under which agent identity, joined by run id to the action — the custody half of the audit-trails story. The log lives on the platform side; it is never a tool result.
- The context is provably secret-free. A standing scan over transcripts, tool-result stores, and prompt caches for live-secret prefixes that must return zero. A hit is not a finding to triage later; it is the exfiltration path already open, and it is the one metric on this page worth paging on.
If you do one thing, make it structural, not procedural: delete every code path where the model can name a secret. Replace key-carrying tool arguments with logical calls, attach credentials at an egress broker, scrub errors before they re-enter the loop, and stand up the "grep the context for a live prefix" scan so a regression pages you the day someone reintroduces the value pattern. Rotation, TTLs and per-target scoping all matter — but they are defence in depth behind the one invariant that actually holds: a secret the model cannot read is a secret no prompt can steal.