DEV Community

Cover image for Three Agents, One Key: Why Your Logs Can't Tell Your Agents Apart
Marco Gonzalez
Marco Gonzalez

Posted on

Three Agents, One Key: Why Your Logs Can't Tell Your Agents Apart

October 15, 2026, an attacker used a stolen credential belonging to ConnectWise Cloud Backup (formerly SkyKick) to request Microsoft Graph tokens in its customers' Microsoft 365 tenants. Huntress put the problem in one line: "The credential works against every tenant the app is consented in." By the next evening it had confirmed unauthorized sign-ins in 162 tenants, and in one of them a sibling ConnectWise app granted itself Global Administrator and planted new secrets on other applications (Semon, 2026).

Two details matter for anyone running agents these days. The sign-ins were genuine: they sat in Microsoft's logs as the backup application, and Huntress noted that none of the surrounding indicators were unique to the attacker. And containment had no fine-grained option. On October 17, ConnectWise disabled seven application identities and took Cloud Backup offline for every customer, along with two other services as a precaution, so scheduled backups stopped everywhere (Ilascu, 2026).

That was one vendor app shared across many customers. Now shrink it to one company and picture a setup that is easy to build today. Three AI agents work a support queue: one reads customer messages, one looks up orders, one issues refunds. To ship quickly, all three call the billing MCP server with the same service-account key. One morning finance finds a refund nobody can explain. The billing log has the timestamp, the amount, the account and the caller. The caller is the shared key, and so is every other caller in the log.

Today's post is about fixing that with standards that already exist. I would also like to hear whether you think it is worth the effort, so there are few questions for you at the end.

Contents

  1. What makes your agent different from a service
  2. Four ways a shared key fails
  3. The standards already exist
  4. AI tools you never registered
  5. Where agentgateway and A2A come in
  6. What this buys you, and what it doesn't

What makes YOUR agent different from a service

Software has called other software with service accounts for decades. A batch job or a microservice follows a path its code decides, so its credential is used in predictable ways. An agent decides what to call while it runs, and the decision depends on whatever text landed in its context. Its credential goes wherever the model's reasoning goes, including places a prompt injection steered it.

Agents also act in two different ways. Some act on their own authority: a refund agent your company deploys calls tools as itself. Others act for a person: a coding assistant a developer connects to your log-search server should carry that developer's authority, and it should lose access when the developer leaves. Each case needs its own OAuth flow.

One scoping note. The principals here are agents and MCP servers. A tool is an operation that an MCP server exposes, running in that server's process, so it has no credential of its own. Which tools an identity may call is an authorization question, and it sits one layer above the topic of this post.

Four ways a shared key fails

Run the refund story as a post-incident review and four separate problems show up.

  1. Attribution. The log records the key, and the key belongs to all three agents. You cannot recover which one made the call.
  2. Blast radius. Anything the key can do, a hijacked agent can do. The refund agent's power is now also the message reader's power.
  3. Rotation. Revoking the key stops all three agents at once. When containment is that expensive, teams under pressure delay it.
  4. Copying. A static secret works from any machine that holds it. If you can't count the copies, you can't say when the exposure ended.

Each agent and each MCP server needs its own identity. It should prove who it is without a secret anyone can copy, its tokens should be accepted only by the servers meant to accept them, and you should be able to switch it off without touching its neighbors.

The standards already exist

None of this needs a new protocol. The pieces are standard OAuth, and the 2026-07-28 revision of the MCP specification says how they fit together for HTTP transports (Model Context Protocol, 2026a). Authorization is optional in MCP, but when you use it, the rules are specific:

  • Discovery. An MCP server MUST publish OAuth Protected Resource Metadata (RFC 9728), which tells the client which authorization server to use.
  • Audience. Every authorization and token request from a client MUST name the target MCP server's canonical URI in a resource parameter (RFC 8707). Servers MUST check that a token was issued for them, and MUST NOT accept or pass along any other token.
  • Client registration. Clients and authorization servers SHOULD support Client ID Metadata Documents. Dynamic Client Registration is now deprecated and kept for backward compatibility.
  • Mix-up defense. Authorization servers SHOULD return the iss parameter (RFC 9207), and clients MUST check it against the issuer they recorded. Issuer confusion is a live bug class: in September 2026 the official MCP TypeScript SDK fixed a flaw that let an MCP server choose which authorization server received a client's stored refresh token and client secret (Model Context Protocol, 2026c).

For agents acting on their own authority, MCP publishes the OAuth Client Credentials extension (io.modelcontextprotocol/oauth-client-credentials), which implementations opt into. It supports client secrets but recommends signed JWT assertions (RFC 7523): the agent proves who it is with a short-lived JWT signed by a private key that never leaves its host (Model Context Protocol, 2026b). That answers most of problem 4: nothing secret is sent over the wire or shared with another agent. It does not shrink the blast radius on its own, though. Huntress described the ConnectWise credential as a client assertion, and it still worked in every tenant, because one identity served them all.

Three more standards fill in the rest. Token exchange (RFC 8693) lets a component trade the token it received for a new one aimed at the next hop. Whether the new token is actually narrower depends on the policy you configure at the authorization server. The JWT access-token profile (RFC 9068) tells resource servers to reject tokens whose aud does not name them. SCIM (RFC 7643 and RFC 7644) keeps the people and teams who own each identity in sync with your directory.

Here is what an agent's access token looks like once each agent has its own client. The values are illustrative and vary by authorization server.

{
  "iss": "https://idp.example.com/realms/agents",
  "sub": "refund-agent",
  "azp": "refund-agent",
  "aud": "agent-gateway",
  "scope": "billing.refund",
  "iat": 1793270400,
  "exp": 1793270700
}
Enter fullscreen mode Exit fullscreen mode

Three claims do most of the work. sub is who the token speaks for, azp (authorized party) is the client it was issued to, and aud is the only party that should accept it. The token lives for five minutes, so a leaked copy has a short shelf life.

AI tools you never registered

The harder case is the coding assistant your developers bring with them. You never registered it, and you don't want every installation registering itself. With a Client ID Metadata Document, the client is described once, by whoever publishes it. The vendor puts a small JSON file on its own HTTPS domain, and the file's address doubles as the client's ID (Parecki & Smith, 2026):

{
  "client_id": "https://scribe.example/oauth/client.json",
  "client_name": "Scribe",
  "client_uri": "https://scribe.example",
  "redirect_uris": ["https://scribe.example/oauth/callback"],
  "grant_types": ["authorization_code"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}
Enter fullscreen mode Exit fullscreen mode

The authorization server fetches the document, checks that client_id matches the URL exactly, and refuses shared-secret authentication methods. The developer signs in with the authorization-code flow and PKCE, so the token names the developer as sub and the tool as azp. Your decision moves from "approve this installation" to "do we trust this publisher?", which is the pattern the draft expects from enterprises that want to onboard specific clients.

This part is still young. The draft is at revision 02, and Keycloak, the open-source authorization server I use for this work, marks both its Client ID Metadata Document support and its resource-indicator support as experimental (Keycloak, n.d.). Test it against the version you run.

Where agentgateway and A2A come in

Checking tokens in every MCP server works, but it repeats the same code in every server. An agent gateway gives you one place to validate every inbound token and one place to mint the narrower token each backend should see. agentgateway, an AAIF project, does both. Its config schema (agentgateway, n.d.) has an mcpAuthentication policy that validates the caller's JWT and serves the protected-resource metadata MCP clients need. It also has a backendAuth.oauthTokenExchange policy that exchanges the caller's token for one addressed to the backend before forwarding the request:

# Policies on the route in front of the billing MCP server.
# Illustrative: validated against the published schema, not run.
mcpAuthentication:
  issuer: https://idp.example.com/realms/agents
  audiences: [agent-gateway]
  provider:
    keycloak: {}
  resourceMetadata:
    resource: https://gateway.example.com/billing/mcp
    scopesSupported: [billing.refund]
backendAuth:
  oauthTokenExchange:
    host: idp.example.com:443
    path: /realms/agents/protocol/openid-connect/token
    clientAuth:
      method: privateKeyJwt        # the gateway has no shared secret either
      clientId: agent-gateway
      signingKey:
        file: /etc/agentgateway/keys/gateway.pem
      assertionAudience: https://idp.example.com/realms/agents
    audiences: [billing-mcp]
Enter fullscreen mode Exit fullscreen mode

The agent's token names only the gateway. The billing server receives a token that names only the billing server, so a token lifted from the billing server can't be replayed against your other backends. A stolen agent token still works at the gateway until it expires, which is why the lifetime is short.

Agent-to-agent delegation is where this matters most. When the message-reading agent hands a task to the order agent over A2A (Agent2Agent), and the order agent calls a tool with its own token, the tool sees the order agent with all of its rights. The work started with a customer message that a different agent read. That is the confused-deputy problem. Token exchange lets each hop carry the original caller forward instead of silently swapping in the more powerful agent. Exactly what the exchanged token records about who acted for whom depends on your authorization server's configuration, which is one of my questions below.

What this buys you, and what it doesn't

With one identity per agent, the refund story ends differently. The billing log names the refund agent. The gateway's log of each hop shows which agent handed work to which. You can disable one agent's client and leave the other two running, and there is no shared key to rotate.

Identity does not stop the refund. A legitimate agent with a valid token, following instructions someone hid in a customer message, still passes every check in this post. Limiting what that agent may do with a tool, bounding delegated tasks and inspecting content are separate layers. The point of identity is that when something goes wrong, you know who did it and you can stop exactly that one.

A sub also names a principal, not a single request. Two concurrent refunds by the same agent share it, so you still need a request ID that travels with each call if you want to join logs across hops.

Agree? talking nonsense? drop me a message with your comments/questions and I'll get back to you.

Happy learning!

References

agentgateway. (n.d.). agentgateway configuration schema [JSON Schema]. Retrieved October 29, 2026, from https://agentgateway.dev/schema/config

Ilascu, I. (2026, October 17). Hackers breach Microsoft 365 tenants via ConnectWise supply-chain attack. BleepingComputer. https://www.bleepingcomputer.com/news/security/hackers-breach-microsoft-365-tenants-via-connectwise-supply-chain-attack/

Keycloak. (n.d.). Integrating with Model Context Protocol (MCP). Retrieved October 29, 2026, from https://www.keycloak.org/securing-apps/mcp-authz-server

Model Context Protocol. (2026a). Authorization [Specification revision 2026-07-28]. Retrieved October 29, 2026, from https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization

Model Context Protocol. (2026b). OAuth client credentials [Extension]. Retrieved October 29, 2026, from https://modelcontextprotocol.io/extensions/auth/oauth-client-credentials

Model Context Protocol. (2026c, September 30). MCP TypeScript SDK: OAuth client could send credentials to an authorization server chosen by the MCP server (GHSA-6qxp-vccf-f47h, CVE-2026-104850). GitHub Advisory Database. https://github.com/advisories/GHSA-6qxp-vccf-f47h

Parecki, A., & Smith, E. (2026). OAuth Client ID Metadata Document (draft-ietf-oauth-client-id-metadata-document-02). Internet Engineering Task Force. https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/

Semon, J. (2026, October 16; updated October 27). ConnectWise (SkyKick) vendor credentials abused in M365 attacks. Huntress. https://www.huntress.com/blog/vendor-credential-enables-unauthorized-access

Top comments (1)

Collapse
 
yuhaixia profile image
Yuhai Xia •

This is the part that finally clicked for me. I used to share one API key across a couple of helper agents because it was just easier, and then one log line went weird and I honestly could not tell which agent had done it. That attribution bit is exactly what bit me.

What helped in my own setup was giving each agent its own identity and keeping the tokens short-lived, so a leak did not feel like a disaster. Rotation is still the annoying part though — I have definitely put off rotating a shared key because I knew it would break three things at once, which is kind of the point you are making.

The gateway idea makes a lot of sense to me as the next step, especially for the delegation case where one agent hands work to another. I am curious how you handle the case where the same agent runs two refunds at the same time, since the sub alone would not separate those. Really clear write-up, thanks for laying out the standards instead of inventing a new protocol.