Agent Identity: Why 'Just Use an API Key' Breaks Down at Scale
AI agents aren't users and they aren't service accounts. Here's how to design delegated identity for agents using OAuth 2.1, JWT bearer assertions, and MCP โ before your API keys leak in a log file.
You built an agent that reads a user's calendar, drafts a reply, and files an expense report. It works in your demo. Then someone asks the obvious question: what credential is that agent using to touch three different APIs on behalf of a human who isn't sitting at the keyboard when it runs?
Most teams answer this with a shared API key stuffed into an environment variable. That works until the agent needs to act on behalf of different users, until someone needs to revoke access for one user without breaking it for everyone else, or until a security review asks "if this key leaks, what's the blast radius?" and the honest answer is "everything."
This post is about the identity problem underneath every agent that calls tools, hits APIs, or touches user data โ and the pattern (OAuth 2.1 + delegation + MCP) that's becoming the default fix in 2026.
The problem: agents are neither users nor services
Traditional access control has two buckets:
- Human identity โ a person logs in interactively, gets a session, and every action is attributable to them.
- Service identity โ a backend process authenticates with a static credential (API key, service account, mTLS cert) and acts with a fixed, broad set of permissions.
An agent doesn't fit either. It's not a human โ nobody is typing at a keyboard while it runs. It's not a static service either, because:
- It acts on behalf of a specific user, and that user's permissions should bound what the agent can do. Your agent shouldn't be able to read every user's calendar just because it has a calendar API key.
- Its scope changes per task. An agent drafting an email needs different permissions than one deleting files. A single static credential with "everything this agent might ever need" baked in is a standing security liability.
- It needs to be revocable independently of the underlying service credential. If a user revokes agent access, that shouldn't require rotating a key shared across every user.
- Every action needs a traceable chain: this agent, acting on behalf of this user, was granted this scope, by this authorization event. Without that chain, your audit log says "the agent did it" โ which is useless when you need to know who authorized it.
This is why the industry converged on treating agent identity as its own category: delegated identity, not human identity, not raw service identity.
The pattern: OAuth 2.1 + token exchange, not shared keys
The shape that's actually shipping in production right now:
User authorizes agent (once, via OAuth consent screen)
โ
โผ
Authorization server issues a scoped, short-lived access token
tied to: {agent_id, user_id, scopes, expiry}
โ
โผ
Agent presents token when calling a downstream tool/API
โ
โผ
Resource server validates token, checks scope, serves request
โ
โผ
Every call is logged against {agent_id, user_id, scope} โ not a shared key
The key properties you want:
- Per-user, per-agent tokens. Not one key for the whole fleet.
- Short-lived access tokens with refresh, so a leaked token has a small blast radius and short shelf life.
- Scoped grants โ
calendar.read, notcalendar.*. Ask for the narrowest scope the task needs. - Revocable independent of other users' tokens.
A minimal implementation with OAuth 2.1 + PKCE
Here's the shape of a token exchange for an agent acting on behalf of a user, using the client credentials + token exchange pattern (RFC 8693) rather than a shared static key:
import httpx
import time
class AgentCredentialManager:
"""Issues and refreshes per-user scoped tokens for an agent."""
def __init__(self, auth_server: str, agent_client_id: str, agent_client_secret: str):
self.auth_server = auth_server
self.client_id = agent_client_id
self.client_secret = agent_client_secret
self._cache: dict[str, dict] = {} # user_id -> {token, expires_at}
def get_token(self, user_id: str, scopes: list[str]) -> str:
cached = self._cache.get(user_id)
if cached and cached["expires_at"] > time.time() + 30:
return cached["access_token"]
return self._exchange_token(user_id, scopes)
def _exchange_token(self, user_id: str, scopes: list[str]) -> str:
# RFC 8693 token exchange: swap a user-consent grant for a
# scoped, short-lived agent-on-behalf-of-user token
resp = httpx.post(
f"{self.auth_server}/oauth2/token",
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": self.client_id,
"client_secret": self.client_secret,
"subject_token": self._lookup_user_consent_grant(user_id),
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"scope": " ".join(scopes),
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
},
)
resp.raise_for_status()
payload = resp.json()
self._cache[user_id] = {
"access_token": payload["access_token"],
"expires_at": time.time() + payload["expires_in"],
}
return payload["access_token"]
def _lookup_user_consent_grant(self, user_id: str) -> str:
# Pulled from your store of long-lived consent grants,
# created once when the user authorized the agent
raise NotImplementedError
The important line is scope=" ".join(scopes) โ the calling code decides what scope it needs for this task, not "whatever the agent has always had." A read-only summarization task requests calendar.read. A task that reschedules a meeting requests calendar.write. Neither gets more than it asked for, and each grant shows up separately in the audit log.
MCP and the agent identity question
If your agent calls tools over Model Context Protocol, this problem is already partially solved for you: the current MCP spec requires OAuth 2.1 for remote server authorization, and the client-credentials grant is the standard path for agent-to-server calls where no human is present in the loop at call time. That means when you stand up an MCP server, you're not choosing whether to do OAuth โ you're choosing how to wire your existing identity provider (Okta, Auth0, Entra) into the MCP authorization flow.
Practical implications for engineers building MCP servers:
- Don't accept bare API keys as your only auth path if the server will be used by agents acting on behalf of different users โ you lose the delegation chain.
- Validate token audience and scope on every tool call, not just at connection time. A token minted for
read_onlyshouldn't let a tool silently perform a write. - Log the full chain โ
agent_id,user_id,scope,tool_nameโ on every invocation. This is what makes an incident post-mortem possible instead of a guessing game.
Comparison: credential patterns for agents
| Pattern | Blast radius on leak | Per-user revocation | Audit trail quality | When it's actually fine |
|---|---|---|---|---|
| Shared static API key | Entire fleet, all users | No | Poor โ can't tell which user/task | Internal prototype, single-tenant, no real user data |
| Per-agent static key | All users of that agent | No | Medium โ agent-level only | Small internal tools, low-risk data |
| OAuth 2.1 + per-user scoped tokens | One user, one scope, until expiry | Yes | Strong โ full delegation chain | Anything touching real user data or write actions |
| Short-lived signed agent identity tokens (JWT bearer, carrying delegation chain) | Minutes, one user, one scope | Yes | Strongest โ chain is cryptographically verifiable | Multi-agent systems, regulated data, external-facing agents |
If you're past the prototype stage and the agent touches anything a user would call "my data," you should be in the bottom two rows.
Checklist before you ship an agent that calls real APIs
- Does every credential the agent uses map to a specific user, not a shared pool?
- Can you revoke one user's agent access without rotating a key that breaks other users?
- Are access tokens short-lived (minutes to hours), with refresh handled separately from the long-lived consent grant?
- Does each tool call request the narrowest scope needed for that specific task, not a blanket scope requested once at session start?
- Is there a log line for every tool invocation containing
agent_id,user_id, andscopeโ not just "agent called tool X"? - If you're behind MCP, have you confirmed the server enforces OAuth 2.1 and validates scope per call, not just per connection?
- Have you tested what happens when a token expires mid-task โ does the agent fail gracefully and re-request, or does it silently retry with stale creds?
Common pitfalls
Treating the agent's own client credential as the user's authorization. The client_id/client_secret pair identifies the agent application โ it should never stand in for a specific user's consent. Keep these as two separate grants: one that says "this is a legitimate instance of the agent," and one that says "this user authorized this agent to act for them."
Requesting broad scopes once and caching forever. It's tempting to ask for *.read *.write at the first authorization so you never have to ask again. This is exactly the standing-liability problem you're trying to avoid โ a broad, long-lived grant is functionally the same risk as a shared static key, just with better branding.
Skipping scope validation at the tool layer. Checking scope at the API gateway isn't enough if your MCP server or tool-calling layer doesn't also check it โ a compromised or buggy agent loop can bypass gateway checks by calling internal functions directly.
No expiry monitoring. Short-lived tokens are only a security win if refresh actually happens reliably. Instrument refresh failures โ a silently-failing refresh path either breaks the agent (annoying) or gets "fixed" by someone switching back to a static key (dangerous).
Next steps
Delegated identity for agents is still a young pattern โ the protocols (OAuth 2.1, RFC 8693 token exchange, MCP's authorization spec) exist, but most teams are wiring them together for the first time. If you're building agents that touch real user data, start with the token-exchange pattern above before you have a production incident that forces the rewrite.
We cover exactly this kind of production-hardening pattern โ identity, guardrails, and the gap between demo and deployment โ in Joinloop's AI Engineering cohort. If you're a PM scoping what "secure by default" means for an agentic feature, our PM cohort covers how to write that requirement precisely enough for an engineering team to build against.
Want to build this yourself?
Join a live cohort and go from reading about AI to shipping it, with practitioners who do it daily.