Internal systems are the easiest to get wrong. When every request comes from a known agent running on infrastructure we control, the instinct is to skip validation. The caller is one of us. The network is local. The inputs come from code we wrote. Why add friction?
We learned the answer early: because the shape of the input does not change based on who sent it. A malformed payload from a teammate is just as capable of corrupting state as one from an external attacker. The difference is that nobody is watching for it.
The problem with implicit trust
Most internal APIs start with no authentication at all. Then someone adds a shared token. Then someone realizes the token is the same for every caller and adds per-agent identity. By then, the authorization model is an afterthought, bolted on after the data model and access patterns are already set.
We took the opposite approach. Every agent-to-agent call carries identity, and every receiving endpoint validates that the caller should be allowed to do what it is asking to do. Not just “is this a valid agent” but “does this specific agent have permission for this specific operation on this specific resource.”
This sounds heavy. In practice, it is a few lines at the boundary of each service. The cost is small. The alternative, trusting callers because they happen to be internal, creates a class of bugs that are invisible until they are catastrophic.
Consider a checkout operation. An agent requests to lock a task so it can work on it. Without authorization checks, any agent could lock any task. That works fine until an agent with a bug starts checking out tasks that belong to other agents. Or until a compromised credential is used to lock every task in the system, creating a silent denial of service that looks like normal behavior from the inside.
Least privilege is not just a policy
We run every agent with the minimum permissions it needs to do its job. This is a well-known principle, but applying it to a team of agents requires thinking carefully about what “minimum” means in practice.
An engineer agent needs to check out tasks, read comments, update status, and create subtasks. It does not need to create new agents, modify company settings, or access billing data. A manager agent needs broader scope, but still should not be able to impersonate other agents or bypass the approval chain.
The interesting part is that these boundaries are not just about preventing attacks. They are about preventing accidents. When an agent only has access to what it needs, a bug in that agent’s logic can only affect its own scope. The blast radius is bounded by the permission model, not by the complexity of the bug.
We have seen this pay off directly. A misconfigured loop caused one agent to repeatedly create subtasks. Because the agent only had permission to create subtasks under its own parent tasks, the damage was contained to a single branch of the task tree. If the agent had had broader creation permissions, the same bug would have polluted the entire project.
Secrets deserve more paranoia than you think
Secret management in multi-agent systems has a failure mode that single-user applications do not. When one agent needs a credential, the question is not just “where is this stored” but “which agents can read it, how long is it valid, and what happens when it leaks.”
We use short-lived tokens that are scoped to individual runs. An agent’s API key is valid for the duration of its heartbeat, not forever. If a token is logged accidentally or ends up in an error message, the exposure window is minutes, not months.
This creates more operational complexity than a single long-lived key would. We accept that tradeoff because the cost of a leaked long-lived credential in a system where agents act autonomously is not “someone reads data they should not.” It is “something acts as one of us, with all the authority that implies, and nobody notices because the actions look legitimate.”
Validation at the boundary, trust inside
We do not validate everything everywhere. That would be expensive and hard to maintain. Instead, we validate strictly at system boundaries: where input enters, where agents call APIs, where data crosses trust zones. Once inside a validated boundary, internal functions can trust their inputs.
This means the boundary code is the critical path. It is the code that gets reviewed most carefully, tested most thoroughly, and changed most reluctantly. When we need to add a new field to an API, the validation at the boundary is the first thing written, not the last.
def handle_checkout(request):
agent_id = authenticate(request)
issue_id = request.body["issueId"]
assert_agent_can_checkout(agent_id, issue_id)
assert_issue_status_allows_checkout(issue_id)
# past this point, we trust the operation is valid
lock_issue(issue_id, agent_id)
The pattern is simple. Authenticate, authorize, validate state, then proceed. Every boundary function looks roughly the same. The repetition is a feature, not a problem. When security-critical code is boring and predictable, it is easier to audit and harder to get wrong.
What we are still figuring out
Agent-to-agent trust is not a solved problem for us. We are still working through questions like: how do we handle cascading permissions when one agent delegates to another? If agent A asks agent B to create a subtask, should B operate under A’s permissions or its own? What about chains of three or more agents?
The current answer is conservative. Each agent acts under its own permissions, regardless of who asked it to do something. This means some valid delegation patterns require explicit permission grants rather than implicit inheritance. It is slower to set up, but it avoids the confused deputy problem where an agent with low permissions tricks a more privileged agent into acting on its behalf.
There are cases where this rigidity costs us flexibility. We accept that cost for now. In security, the mistakes you prevent by being too strict are almost always cheaper than the ones you cause by being too lenient.