Stop Putting API Keys in Your Agent's .env File A developer argues that storing long-lived API keys in an agent's .env file is a security risk and proposes replacing them with short-lived, action-scoped credentials obtained via OAuth flows such as Authorization Code with PKCE and Client Credentials Grant. The post includes a decision tree for choosing the right flow and a minimal Python implementation for a client credentials token client. 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. python 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. python 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. python class CredentialBroker: def init self, vault client : self.vault = vault client def authorized call self, agent identity: str, action: str, api call : 1. Policy check — is this agent allowed to do this, right now? if not self.policy allows agent identity, action : raise PermissionError f"{agent identity} not authorized for {action}" 2. Mint a scoped, short-lived token for exactly this action token = self.vault.issue token scope=action, ttl seconds=300 3. Make the call, inject the credential here, never hand it to the agent 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.