Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 7 min read

Who is the agent acting as?

I spent a good part of the spring looking at how agents were wired into companies' systems, for three clients in three industries. The agents were all different, and the identity problem was the same every time. It goes

I spent a good part of the spring looking at how agents were wired into companies' systems, for three clients in three industries. The agents were all different, and the identity problem was the same every time.

It goes like this. A user in the company's app asks the agent to do something. The request goes to an agent service, which calls a model, decides to use a tool, and calls an internal API or an MCP server. To make that call it uses a credential. At all three companies, that credential was a service account created when the agent was set up, with a token that could do everything the agent might ever need to do, for every user.

So the CRM saw a request from agent-svc. The database saw a connection from agent-svc. The audit log said agent-svc read 400 customer records on Tuesday. Which user asked for those records, whether they were allowed to see them, and whether the 400 was one request or forty, wasn't in any log, because the user's identity had been dropped at the first hop.

This isn't a hypothetical worry. It's what makes the two most common agent incidents possible: an agent reading data for a user who shouldn't have had it, and an agent tricked by injected text into doing something with an access level no human in the company has.

Three questions every call needs to answer

Before the fix, the standard. For any action an agent takes against a system, that system should be able to answer three things.

Which human is this for? The person, with their permissions, in their session, not just which service.

Which agent is doing it? A different agent, or a different version of the same one, is a different actor and should be told apart.

What was the agent allowed to do for this task, and was this inside it? The task the user asked for sets a scope, and a call outside it should be refused, whatever the user could do in general.

The service account model answers none of these. The only thing it answers is whether the caller was the agent, which is the least useful question.

The pieces that exist now

The good news is that this is an old problem with a new face, and the identity people have been building the pieces for a while. Three of them matter.

Token exchange, RFC 8693, lets a service holding a user's token trade it for a new token that's scoped down and carries both the user's identity and the service's. The agent service gets the user's access token from the app, exchanges it at the identity provider for a token that says "user U, acting through agent A, permitted to do X", and uses that downstream. The downstream sees both the user and the agent. The act claim in the resulting token is the delegation chain.

Resource indicators, RFC 8707, let a token be minted for one specific downstream. A token for the CRM can't be replayed against the database, because the CRM's identifier is in the token's audience and the database checks for its own.

The MCP authorization specification, updated in 2025, made both of these the expected pattern for MCP servers. A client gets a token for a specific server, the server validates the audience, and the spec says outright that servers must not accept tokens issued for something else.1

What it looks like wired up

For one of the three clients, the flow after the change looked like this:

Diagram

The agent service never holds a credential of its own that can reach the CRM. It holds the user's token for the length of the request, exchanges it for the narrowest thing that can finish the task, and uses that. When the request ends, the scoped token expires, usually within minutes.

On the CRM side, the MCP server checks that the token's audience is itself and that the subject is a known user, then enforces that user's permissions exactly as if they'd clicked in the UI. The act claim goes into the audit log. Now the log says: user Priya, through the triage agent v2.3, read 12 contacts, scope contacts:read, at 14:22. That's the sentence you want to be able to write when the security team asks.

There isn't much code on the agent side. The exchange is one call:

async function tokenFor(userToken: string, audience: string, scope: string) {
  const res = await fetch(`${IDP}/oauth/token`, {
    method: "POST",
    headers: { "content-type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
      subject_token: userToken,
      subject_token_type: "urn:ietf:params:oauth:token-type:access_token",
      actor_token: await agentIdentityToken(),
      actor_token_type: "urn:ietf:params:oauth:token-type:jwt",
      audience,
      scope,
    }),
  });
  if (!res.ok) throw new Error(`token exchange failed: ${res.status}`);
  return (await res.json()).access_token as string;
}

The actor_token is the agent's own identity, issued to the agent service by the identity provider through whatever workload identity mechanism you already have: a Kubernetes service account token federated to the provider, a SPIFFE identity, a cloud instance identity. That's how the agent proves it's the agent, separately from the user proving they're the user.

Scope is the task, not the user

Scope took the most arguing. The instinct is to give the exchanged token the user's full set of permissions, since the user could do all of that anyway. That instinct is exactly what makes injection attacks work.

If a user with admin rights asks the agent to summarise a ticket, the agent needs tickets:read for one ticket. It doesn't need users:delete, even though the user has it. If the ticket contains text trying to get the agent to delete a user, the attempt should fail at the CRM with a scope error, and not go through just because the human behind the agent happened to be powerful.

So the scope in the exchange comes from the task, and the agent service has a small table mapping task types to the scopes they need. It's boring code. It's also the line between an injection being an incident and an injection being a log line that says "scope error, contacts:delete, refused".

When a task needs more than the table gives it, which does happen, the agent asks. The user sees "this task needs permission to update contacts, allow?", and their yes becomes a new exchange with a wider scope. That's consent, the same shape as a mobile app asking for camera access: at the moment it's needed, for the specific thing, with a human in the loop.

The audit log is the point

The incidents were the motivation, but what actually got the budget approved at all three clients was the audit log. Regulated industries have to be able to say who accessed what. An auditor won't accept "the agent did", or a service account, and the companies knew that. The identity work was the price of being allowed to run the agent at all.

Once the delegation chain is in the token, the log writes itself, because every downstream system already logs the subject of the token it gets. There's no agent specific logging to build. The user, the agent and the scope are in the same field the systems have always logged, and the reports compliance already runs pick them up without changes.

Where it is still rough

Not every downstream supports token exchange. Some legacy internal APIs take a single API key and that's that. For those, the pattern is a thin proxy that does the exchange and the audience check, holds the legacy key, and forwards the request with the user's identity in a header the legacy system is taught to log. It's a compromise, and it beats the service account.

Identity providers vary in how well they implement RFC 8693. Some support it fully and some support a subset.2 Test the exchange flow against your actual provider before you commit to the design.

And most MCP servers from the ecosystem don't validate audience yet. If you connect a third party server, assume its token handling is a static key until you've read the code, and put the proxy in front of it.

If you do one thing

Find out what credential your agent uses to reach your most sensitive system, and look at that system's access log for the agent's entries. If they show the agent's name and not a person's, you've got the problem, and the first step is to stop the agent holding that credential at all. Everything else follows once the agent borrows the user's identity for the length of a task instead of owning a permanent one of its own.

Originally published at zeybek.dev.

  1. Most MCP servers out there still take a static API key at startup, but the protocol is shaped right, and servers that follow it can be given tokens per user and per task. ↩

  2. One I ran into only supported it for tokens it had issued itself, which broke a federation setup. ↩

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.