Zero Trust as a Daily Habit for MCP Agents
The cron entry changed on a Thursday night. By Friday morning, all we had in the audit log was this:
executed
No actor. No original command. No before and after. We spent two hours the next morning trying to figure out whether a human, an agent, or some policy had moved the cleanup job. The log had recorded the conclusion and discarded the reasoning. That morning is why I now treat zero trust for MCP agents as something I practice daily.
The first multi-agent incident will look small. It will look like a cached permission. Agent A writes a memory record. Agent B reads it later and acts. Nothing gets re-verified. That is ambient authority. Shared memory becomes shared privilege. A permission can outlive the task that created it, and nobody notices until something changes production.
Zero trust for MCP only works as a loop you run on every call: authenticate the caller, authorize the exact resource or tool, constrain the token, log the decision, and expire the authority. Every call. No exceptions for agents that "already proved" themselves.
We run four controls on every call. None of them are a gateway checkbox.
We started with long-lived service accounts. It was convenient. It was also how we gave every agent the same keys to the building. If a memory record got poisoned or an agent loop went wrong, the blast radius was every tool that service account could reach.
Now we use OAuth 2.1 resource indicators with short-lived access tokens. Each token is audience-bound to one MCP server. The scope is narrow enough to be boring. If an agent only needs to read a project memory namespace, the token says memory:read:tenant/project. If it needs to write one namespace, it says memory:write:namespace. If it needs to update cron, it says tool:invoke:cron:update. That token cannot read secrets. It cannot deploy. It cannot touch another tenant.
The first time we did this, we broke a workflow. A tool needed to read a memory record to verify context, but the token we minted for the write path didn't include memory:read. The agent kept getting denied. We had scoped by agent identity, not by call path. The fix was to mint the token for the task: tool:invoke:cron:update plus memory:read:tenant/project for that one call, with a short TTL. The lesson was simple. A capability token represents the task, not the agent's permanent rank.
Our cron incident wasn't an authentication problem. It was a logging problem. We had logged executed and called it observability. That log line was a rumor with a timestamp.
We changed the audit record so a denied or allowed action carries intent, actor, resource, before, after, diff, token ID, and policy decision. Something like:
{
"event": "config.change",
"actor": "agent:maintenance-7",
"intent": "move cron cleanup window",
"resource": "cron.cleanup",
"before": "0 3 * * *",
"after": "0 4 * * *",
"diff": "-0 3 * * *\n+0 4 * * *",
"token_id": "cap_...",
"decision": "allow",
"policy": "change-window:night"
}
Now when someone asks who changed the schedule, we don't reverse-engineer from the outcome. The log carries the reasoning. We also log denials. A denial is a signal that a token is too narrow or an agent is trying to do something outside its lane. Both are useful.
We enforce this in the MCP server, not in the prompt. The agent cannot submit a cron update without before, after, actor, and intent. If the diff is missing, the call is rejected. The prompt can be ignored. The server cannot.
Tokens need to die. We tried longer TTLs because re-issuing felt noisy. That was a mistake. A memory record cached at 09:00 could be used at 21:00 by an agent that had no business acting on it. Now tokens are short-lived, and refresh is tied to the task. If an agent is compromised, the blast radius is one call path, not one day.
We also bind the token to a resource indicator. A token minted for mcp://memory-server cannot be replayed against mcp://tool-server. That sounds obvious. We still had to write it down after a test where an agent passed a memory token to a tool server and the tool server accepted it because it only checked the signature. Audience binding matters. It is the difference between a key and a master key.
Shared memory behaves like a message bus with history. It is not a shared brain. Agent A writes a record. Agent B reads it. If B acts without re-checking provenance and policy, you have ambient authority moving through the system at the speed of a memory read.
We added provenance fields to memory records: writer agent ID, token ID, timestamp, intent, and the scope under which it was written. When B reads a record, it re-checks the record against B's per-call token. Does B have memory:read for that namespace? Is the record expired? Is the action B wants to take allowed by its own token? This breaks the assumption that a read is a permission to act.
We tried signing every memory record with a shared secret. It became a rotation mess. Now we use per-agent key IDs with asymmetric signatures and a revocation list. It has gaps, but shared secrets were worse. Cryptographic perfection would be nice. The practical goal is smaller: B cannot inherit A's authority just because A wrote a record.
We tried strict zero trust on every memory read. Latency went up. Agent loops got slower. We ended up with a split: high-risk tools such as cron, deploys, secrets, and network policy require per-call tokens and full diff logs. Low-risk reads get short-lived session tokens with audience binding and sampled audit. We are still measuring that compromise. It is written down in our policy, not hidden in a config file. Some teams will need stricter. Some can accept more risk. The daily habit matters more than the diagram.
That Thursday night, the cron change went out with executed. We could not tell if it was a human, an agent, or a bad policy. Now every agent change to system config must carry a diff. No diff, no change. The MCP server rejects the call. That one rule would have saved us two hours. It will not stop every incident. But it turns a dead end into a readable trail.
If your agents can change production and your audit log says executed, you don't have observability. You have a rumor. Start with the diff. Then bind the token. Then expire it. Then do it again on the next call.
Top comments (0)