Open any repo with an AI agent in it and you'll find the same thing nine times out of ten: a .env
file with an OpenAI key, a GitHub token, maybe a database URL, all long-lived, all scoped to basically everything, all sitting in plaintext on disk.
This works fine in a demo. It's also exactly the pattern that turns a single leaked file, a compromised dependency, or a prompt-injected tool call into total account takeover, because the credential the agent is holding doesn't expire, doesn't know what task it's for, and can usually do a lot more than the task actually needs.
The fix isn't "better secrets management." It's not treating the agent as something that holds credentials at all. Below is the pattern that's actually converging across MCP, Auth0, WorkOS, and a few other places building this right now, plus a minimal implementation you can steal.
A static API key answers one question: is the bearer allowed to do this category of thing, forever, until someone remembers to rotate it. That's a bad shape for an agent, because an agent isn't one actor doing one job. The same agent process might read a customer record for one user, then modify a deployment config for a completely different user, thirty seconds later, using the same credential the whole time.
Give that credential broad scope once, and every one of those very different actions is now authorized by the same blanket permission. If the agent gets manipulated into doing something it shouldn't β a prompt injection, a bad tool call, a hallucinated plan β the credential doesn't know the difference. It was never scoped to the task in the first place.
The fix is to stop giving the agent a credential at all, and instead give it a way to ask for one, just before it needs it, scoped to exactly that action.
Before you touch any code, figure out which of these your agent actually is, because it changes the whole flow:
Does the agent act on behalf of a specific logged-in user?
βββ YES β Authorization Code + PKCE
β (agent inherits a narrow, user-consented scope)
β
βββ NO β Is it a background job / daemon / pipeline with no user in the loop?
βββ YES β Client Credentials Grant
β (agent authenticates as itself, gets a short-lived token)
β
βββ UNCERTAIN β Does it ever touch a specific user's data mid-task?
βββ YES β treat as user-delegated, use token exchange
βββ NO β Client Credentials, narrowest scope you can define
Most agent architectures actually need both at different points β client credentials for the agent's own background identity, and token exchange whenever it needs to act as a specific user for one call.
This is the baseline for an autonomous agent with no user in the loop. The agent authenticates as itself and gets a token that dies fast.
import time
import httpx
class AgentTokenClient:
def __init__(self, token_url, client_id, client_secret):
self.token_url = token_url
self.client_id = client_id
self.client_secret = client_secret
self._cached = None # (token, expires_at, scope)
def get_token(self, scope: str) -> str:
if self._cached and self._cached[2] == scope and self._cached[1] > time.time() + 5:
return self._cached[0]
resp = httpx.post(self.token_url, data={
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_secret": self.client_secret,
"scope": scope,
})
resp.raise_for_status()
data = resp.json()
expires_at = time.time() + data["expires_in"]
self._cached = (data["access_token"], expires_at, scope)
return data["access_token"]
Two things worth doing that people skip: request the narrowest scope string that covers the actual call you're about to make (not "crm:*", but "crm:read:invoices"), and set the TTL as low as your workflow tolerates. Five to fifteen minutes for anything sensitive, an hour at most for read-only calls. There's no refresh token in this flow on purpose β if the token expires, the agent just re-authenticates. That's a feature, not friction.
This is the one most agent codebases skip entirely, and it's the one that actually matters once your agent does anything on behalf of different users.
The idea, from RFC 8693: your agent holds a base token that proves who the agent is, and exchanges it for a narrower, audience-restricted token that proves what this specific call is allowed to do, for this specific user, right now.
def exchange_for_scoped_token(base_token: str, target_resource: str, action: str) -> str:
resp = httpx.post(TOKEN_EXCHANGE_URL, data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"subject_token": base_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"resource": target_resource,
"scope": action, # e.g. "invoices:refund"
})
resp.raise_for_status()
return resp.json()["access_token"]
The payoff: if this narrow token leaks, the blast radius is one action, on one resource, for a window measured in minutes β not the agent's entire standing access to everything it's ever touched.
The cleanest version of this removes the agent from the credential-handling business entirely. The agent doesn't request a token from an auth server directly β it asks a broker for permission to do something, and the broker injects the credential on the way out to the actual API call.
class CredentialBroker:
def __init__(self, vault_client):
self.vault = vault_client
def authorized_call(self, agent_identity: str, action: str, api_call):
if not self.policy_allows(agent_identity, action):
raise PermissionError(f"{agent_identity} not authorized for {action}")
token = self.vault.issue_token(scope=action, ttl_seconds=300)
try:
return api_call(token)
finally:
self.vault.revoke(token) # belt and suspenders
The agent's code just calls broker.authorized_call("refund-agent", "invoices:refund", do_refund)
and never touches a credential at all. If the agent's process gets compromised β prompt injection, malicious tool output, whatever β there's nothing to steal, because nothing long-lived was ever there.
If you're building on Model Context Protocol, some of this is handled for you by design. MCP's authorization spec deliberately treats the MCP server as an OAuth resource server, not an authorization server β meaning the server that handles your tool calls isn't the one deciding who's allowed to call them. That job belongs to a dedicated identity provider, and the MCP server's only responsibility is validating the token it's handed.
That split matters more than it sounds. It means you can swap out or upgrade your auth provider without touching every MCP server you run, and it means tool-level authorization decisions live in one place instead of being reimplemented inconsistently across every integration you build.
If you're rolling your own agent-to-tool protocol instead of MCP, this is worth copying even without the rest of MCP: keep "can this token do this" completely separate from "what does this tool do."
Being honest about the gaps, because this pattern isn't a finished solution yet:
invoices:refund
in the first place, or whether that decision gets reviewed six months later. That's policy work, not code, and most teams skip it because it's the boring part.An agent is not a user and it's not a static service account β it's a new kind of actor that needs its own identity, its own narrowly scoped credentials, and a credential lifetime measured in minutes, not months. If your agent's .env
file has anything in it that doesn't expire on its own, that's the thing to fix this week, before it's the thing in next month's incident report.