{"slug": "stop-putting-api-keys-in-your-agent-s-env-file", "title": "Stop Putting API Keys in Your Agent's .env File", "summary": "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.", "body_md": "Open any repo with an AI agent in it and you'll find the same thing nine times out of ten: a `.env`\n\nfile 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.\n\nThis 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.\n\nThe 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.\n\nA 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.\n\nGive 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.\n\nThe 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.*\n\nBefore you touch any code, figure out which of these your agent actually is, because it changes the whole flow:\n\n```\nDoes the agent act on behalf of a specific logged-in user?\n├── YES → Authorization Code + PKCE\n│         (agent inherits a narrow, user-consented scope)\n│\n└── NO → Is it a background job / daemon / pipeline with no user in the loop?\n          ├── YES → Client Credentials Grant\n          │         (agent authenticates as itself, gets a short-lived token)\n          │\n          └── UNCERTAIN → Does it ever touch a specific user's data mid-task?\n                    ├── YES → treat as user-delegated, use token exchange\n                    └── NO  → Client Credentials, narrowest scope you can define\n```\n\nMost 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.\n\nThis 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.\n\n``` python\nimport time\nimport httpx\n\nclass AgentTokenClient:\n    def __init__(self, token_url, client_id, client_secret):\n        self.token_url = token_url\n        self.client_id = client_id\n        self.client_secret = client_secret\n        self._cached = None  # (token, expires_at, scope)\n\n    def get_token(self, scope: str) -> str:\n        if self._cached and self._cached[2] == scope and self._cached[1] > time.time() + 5:\n            return self._cached[0]\n\n        resp = httpx.post(self.token_url, data={\n            \"grant_type\": \"client_credentials\",\n            \"client_id\": self.client_id,\n            \"client_secret\": self.client_secret,\n            \"scope\": scope,\n        })\n        resp.raise_for_status()\n        data = resp.json()\n        expires_at = time.time() + data[\"expires_in\"]\n        self._cached = (data[\"access_token\"], expires_at, scope)\n        return data[\"access_token\"]\n```\n\nTwo 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.\n\nThis 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.\n\nThe 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.*\n\n``` python\ndef exchange_for_scoped_token(base_token: str, target_resource: str, action: str) -> str:\n    resp = httpx.post(TOKEN_EXCHANGE_URL, data={\n        \"grant_type\": \"urn:ietf:params:oauth:grant-type:token-exchange\",\n        \"subject_token\": base_token,\n        \"subject_token_type\": \"urn:ietf:params:oauth:token-type:access_token\",\n        \"resource\": target_resource,\n        \"scope\": action,          # e.g. \"invoices:refund\"\n    })\n    resp.raise_for_status()\n    return resp.json()[\"access_token\"]\n```\n\nThe 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.\n\nThe 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.\n\n``` python\nclass CredentialBroker:\n    def __init__(self, vault_client):\n        self.vault = vault_client\n\n    def authorized_call(self, agent_identity: str, action: str, api_call):\n        # 1. Policy check — is this agent allowed to do this, right now?\n        if not self.policy_allows(agent_identity, action):\n            raise PermissionError(f\"{agent_identity} not authorized for {action}\")\n\n        # 2. Mint a scoped, short-lived token for exactly this action\n        token = self.vault.issue_token(scope=action, ttl_seconds=300)\n\n        # 3. Make the call, inject the credential here, never hand it to the agent\n        try:\n            return api_call(token)\n        finally:\n            self.vault.revoke(token)  # belt and suspenders\n```\n\nThe agent's code just calls `broker.authorized_call(\"refund-agent\", \"invoices:refund\", do_refund)`\n\nand 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.\n\nIf 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.\n\nThat 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.\n\nIf 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.\"\n\nBeing honest about the gaps, because this pattern isn't a finished solution yet:\n\n`invoices:refund`\n\nin 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`\n\nfile 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.", "url": "https://wpnews.pro/news/stop-putting-api-keys-in-your-agent-s-env-file", "canonical_source": "https://dev.to/justhsnn/stop-putting-api-keys-in-your-agents-env-file-27kk", "published_at": "2026-08-19 06:09:02+00:00", "updated_at": "2026-08-19 06:41:12.380799+00:00", "lang": "en", "topics": ["ai-agents", "ai-safety", "developer-tools"], "entities": ["OpenAI", "GitHub", "MCP", "Auth0", "WorkOS"], "alternates": {"html": "https://wpnews.pro/news/stop-putting-api-keys-in-your-agent-s-env-file", "markdown": "https://wpnews.pro/news/stop-putting-api-keys-in-your-agent-s-env-file.md", "text": "https://wpnews.pro/news/stop-putting-api-keys-in-your-agent-s-env-file.txt", "jsonld": "https://wpnews.pro/news/stop-putting-api-keys-in-your-agent-s-env-file.jsonld"}}