1. Home
  2. Blog
  3. An agent should never hold your access

An agent should never hold your access

Handing an agent a person's credential makes the agent that person. Canopy gives it a token of its own, limited to what both of them may do.

The easiest way to let an agent act for someone is to give it their token. It works on the first try, and that is the problem.

A token doesn't say who is holding it. If an assistant in your product calls your API with Sarah's token, your API sees Sarah. It can do everything Sarah can, including the things she would never ask it to do, and your logs say she did them. A prompt injection in a document the agent reads now has Sarah's whole account to work with.

Asking the agent to be careful doesn't fix this. The limit has to be somewhere the agent can't talk its way past.

Two parties, one token

In Canopy, an agent that acts for a person is a machine client: its own credentials and its own roles. When Sarah asks it to do something, it exchanges her access token for a new one, using OAuth token exchange (RFC 8693).

ts
import { exchangeToken } from "@canopy-io/node";

const { accessToken } = await exchangeToken({
  clientId: process.env.AGENT_CLIENT_ID ?? "",
  clientSecret: process.env.AGENT_CLIENT_SECRET ?? "",
  subjectToken: sarahsAccessToken,
  audience: "https://api.example.com",
});

The token that comes back names both of them. sub is Sarah, act.sub is the agent, and its type is delegated, so Canopy's SDK refuses it on any route that hasn't opted in. It has no refresh token, and it lasts the machine token lifetime or the rest of Sarah's token, whichever is shorter. The agent has to ask again, and Canopy can say no when it does.

Both must hold it, at that place

Its permissions are only the keys both Sarah and the agent hold. That rule alone is common. What matters is where it applies.

Most systems stop at a flat list of scopes: this token may read deals. But in a real organization, Sarah can read the deals in her region and not the ones in the next region over, and the agent should be no different. In Canopy, permissions are granted at a place in a hierarchy, so the question your API asks names the place:

ts
const claims = await verifier.verify(token, { alsoAllow: ["delegated"] });
const acting = delegation(claims);

const { allowed } = await authorizer.evaluate({
  identity_id: claims.sub,
  permission: "deals.read",
  node_id: dealNodeId,
  ...(acting ? { acting_client_id: acting.actingClientId } : {}),
});

The answer allows only when Sarah and the agent both hold deals.read at that node. If the agent's role covers the whole company, it still can't reach a region Sarah can't. If Sarah can reach it but the agent's role stops short, the answer is still no. Neither of them can lend the other anything.

Three copies of one tree: Company, with Engineering and Sales under it, and Frontend under Engineering. Sarah holds deals.read at Engineering, which covers Frontend. The agent holds it at Frontend only. The delegated token reaches only Frontend, the one place both hold it
Sarah holds it at Engineering, the agent at Frontend. The token reaches only where both hold it.

You also opt in per route. alsoAllow: ["delegated"] goes only on the routes where you let an agent act for someone, and everywhere else the token is refused.

We got this wrong first

Canopy runs an MCP server, so an AI assistant can work in your Account for you. When we first built it, a developer signed in and the assistant then borrowed their whole authority. We marked its changes in the audit log as coming through MCP, but the log could not say which assistant it was.

That is the pattern this post argues against, in our own product.

Now the developer decides at sign-in. The consent page asks which Application and Environment the assistant may act in, whether it may only read or also change things, and which of their capabilities it may use. Each call is answered from their roles narrowed to that approval. A read-only assistant is refused writes by the API, not just by having the tools hidden.

The audit row changed with it:

Before
text
Actor:    you@company.com
Channel:  mcp
After
text
Actor:    you@company.com
Channel:  mcp
Through:  Claude Code
Approval: 7d1c…

The change is still attributed to the developer, because it is their authority. But now the row says which assistant used it, and under which approval, so you can tell two assistants apart and revoke one of them.

What the log can say

An exchange is recorded whether it succeeds or not: oauth.token_exchange.issued or oauth.token_exchange.refused, under the agent. An issued exchange names the person. So Canopy's audit log can answer which agent acted for whom, and when.

What the agent then does happens in your API, so that line is yours to write, and the token gives you everything you need for it:

ts
const who = acting ? `${acting.actingClientId}, for ${claims.sub}` : claims.sub;

Write that on every change, and "the assistant did it" stops being the end of the investigation. You can see which agent, for which person, under what they both held.

The short version

  • Don't hand an agent a person's token. A token doesn't say who is holding it.
  • Give the agent its own identity, and exchange the person's token for one that names both.
  • Check both parties at the place being touched, not against a flat list.
  • Record who acted, for whom, and through what.

The details are in Machine Clients and Connect an MCP Client.

Referenced in this post Read the machine clients guidecanopy-io.com