AMOL KHATRIamolkhatri.com
FIELD NOTE

Agents don't log in


Our systems were built for humans who log in. Agents act on behalf of someone else, and unless the system knows that, nobody can tell who asked for a change.

Our systems were built for humans who log in. Agents don’t log in. Generally, agents act on behalf of someone else. That’s what delegation is: a task handed over by someone else to an agent.

Scenario

We transformed our CMS to be more agentic. A user can interact with it using a chat interface and ask questions like “What can I do to improve my site’s SEO?”. The agent analyses it, creates a plan around it and presents it to the user. The user approves the plan, and the agent executes it.

The Problem

This looks completely fine until a client reports an unwanted change on the site, which they say they did not request. We ran into a lot of these issues. We could fix them, but we couldn’t tell who had requested the changes, because the agent made every change using a single service account: an account that belongs to the agent, not a person. So the agent was acting on behalf of the user and making the changes.

The other problem is that the user who delegated the request may not have permission for a particular operation, like publishing the site, but the agent may. It creates a mismatch in authorisation.

Before agents, client has to log in and make the changes in CMS. So it was easy to control what changes they could make and to see who made each change.

So we had two problems to solve:

  1. Visibility: For every change, see who asked and which agent made it.
  2. Control: The agent should only make changes the user is allowed to make, not everything the agent can do.

The Solution

Give the agent its own unique identity, and make it act on behalf of the user by passing it a delegated token: a short-lived token that carries both the user’s identity and the agent’s. The agent has its own identity but no access of its own; everything it is allowed to do comes from the token the user delegated to it. This solves the control problem.

To solve the visibility problem, the agent should always pass both its own identity and the user’s to the backend system. Sometimes an agent calls another agent on the user’s behalf, which creates a delegation chain. We need to make sure that the chain is maintained, so the log records who asked and every agent that acted.

The Implementation

Before any exchange can happen, two things are already in place. Priya has signed in, so the chat app holds her token. And she has allowed cms-agent to act for her, once, the same way you allow an app to access your account, and the auth server records that consent. When Priya asks for a change, the agent asks the auth server for a delegated token, and the auth server decides: if Priya never allowed this agent, or the change is outside her permissions, no token is issued. The agent asks; the auth server decides.

We don’t need to invent anything here. There is already a standard for exactly this: RFC 8693, OAuth 2.0 Token Exchange. It isn’t specific to agents. It was written for any service that acts on behalf of a user, which is exactly the situation an agent is in. It defines two things we need: how to swap a user’s token for a delegated one, and how to record inside that token who is acting.

Token structure

The delegated token is usually a JWT (JSON Web Token): a small signed JSON document. It has three parts, a header, a payload and a signature, and the payload is where the delegation lives. Decoded, the token our agent uses to fix Priya’s banner looks like this:

{
  "iss": "https://auth.example.com",
  "sub": "user:priya",
  "act": { "sub": "agent:cms-agent" },
  "aud": "https://cms.example.com",
  "scope": "cms:banners:write",
  "exp": 1791190200
}
  • sub (subject) is the user the token is for. The backend checks Priya’s permissions, not the agent’s. This is what solves the control problem.
  • act (actor) is the agent doing the work. This is what solves the visibility problem.
  • aud (audience) is the one service this token is valid for, so it can’t be replayed against anything else.
  • scope is what the token allows, and nothing more than this task needs.
  • exp is when it expires. Delegated tokens should live for minutes, not days.
  • iss is the auth server that issued it, and the signature proves nobody changed the payload on the way.

Getting the delegated token

The agent never stores the user’s password, and it doesn’t reuse the user’s token directly. Instead it asks the auth server to exchange it:

POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<Priya's access token>
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&actor_token=<cms-agent's own token>
&actor_token_type=urn:ietf:params:oauth:token-type:jwt
&audience=https://cms.example.com
&scope=cms:banners:write

The subject_token says who the work is for. The actor_token proves who the agent is. Before issuing anything, the auth server checks that Priya has actually allowed this agent to act for her, and that the requested scope is within what Priya is allowed to do. If either check fails, there is no token, so the agent simply can’t do more than the user.

If everything checks out, it returns a new short-lived token:

{
  "access_token": "eyJhbGciOi...",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "token_type": "Bearer",
  "expires_in": 600
}

Passing the token

This part is boring, which is good. The agent sends the delegated token the same way every API call already sends a token, in the Authorization header:

PATCH /banners/home
Authorization: Bearer eyJhbGciOi...

Delegation doesn’t need a new transport. It only needs a different token.

What the backend does with it

  1. Verify the token: signature, issuer, audience and expiry.
  2. Authorize the request using sub and scope, so Priya’s permissions decide what is allowed.
  3. Log both names from the token, so the audit log finally answers our original question:
banner.update · on_behalf_of=user:priya · actor=agent:cms-agent

Delegation chains

When one agent hands work to another agent, the second agent does the same exchange again: the current delegated token becomes its subject_token, and its own token becomes the actor_token. The new token nests the actors, newest first:

"act": {
  "sub": "agent:image-resizer",
  "act": { "sub": "agent:cms-agent" }
}

Each exchange can only keep or narrow the scope, never widen it, so the whole chain stays within what Priya allowed, and the log can show every agent that touched the change.

Don’t forward the user’s own token from one service to the next. This is called token passthrough, and the MCP specification explicitly forbids it. It brings back both of our problems: the next service can’t tell an agent was involved, and the token usually carries far more access than the task needs.