Stop putting API keys in environment variables

The tutorial pattern that everyone copies has six known leak vectors. Here's how each one fires in production, and what to do instead.

April 19, 202610 min read

Stop putting API keys in environment variables.

Open any tutorial for any AI client library and the first three lines are the same. Sign up for an API key. Put it in your environment. Read it with os.getenv or process.env.

That pattern made sense in 2014 when "the alternative" was hard-coding the secret in the source file. It made less sense in 2018 when secrets managers existed but were a pain to wire up. It makes the least sense in 2026, when your OPENAI_API_KEY is no longer being read by code you wrote. It's being read by an autonomous agent that you, on purpose, gave the ability to execute arbitrary subprocesses, read arbitrary files, and call arbitrary URLs.

This post is the case for treating environment variables as the unsafe default they actually are, especially when an AI agent is anywhere in the loop. Six concrete leak vectors first, then what's better.

The pattern everyone copies

This is the canonical onboarding flow for OPENAI_API_KEY, ANTHROPIC_API_KEY, GITHUB_TOKEN, and the 700 other vendor-named env vars you have on your laptop right now.

bash
export OPENAI_API_KEY="sk-proj-..."
python
import os
from openai import OpenAI

client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])

It looks safe. It's not in source. It's not in git. The vendor's docs told you to do this. The first ten Stack Overflow answers tell you to do this. What could go wrong.

A lot of things, it turns out.

The six ways env vars leak

1. Stack traces

A single uncaught exception in production can dump your full process environment into the trace. Sentry, Datadog, Honeycomb, and CloudWatch all default to capturing context around an exception. That context often includes the environment, and the environment includes every secret you exported.

I have personally watched a key escape this way. Library raises an exception. The exception handler attaches the local namespace as breadcrumbs. The breadcrumbs include a config object that was initialized from os.environ. Sentry ingests it. Forty-seven people on the engineering team can now read the key.

The fix everyone reaches for is SENTRY_BEFORE_SEND filters or scrubbers. Those filters use regex. Regex does not catch a key embedded in a base64-encoded JWT inside a config dump three levels deep.

Warning

If you use any error tracker, audit it for environment-variable capture today. Most have an opt-in "include env" setting that defaults to on for self-hosted, and off for SaaS but with an integration that re-enables it.

2. Process listings

On a multi-user box, ps auxe shows every process's full environment. Anyone with shell access can read every other user's secrets.

bash
$ ps auxe | grep python
zriyansh  4827  python agent.py OPENAI_API_KEY=sk-proj-...

Modern Linux distros restrict auxe to your own user by default, but Docker containers, CI runners, and shared dev boxes commonly run without that restriction. If you've ever debugged a container by kubectl exec into a sibling pod, you've had the access required to read every secret your team set.

3. Child process inheritance

A subprocess inherits the parent's environment unless you actively scrub it. Every subprocess. git, curl, node, the build script you forgot about, the agent you launched five minutes ago. Every one of them sees every key.

This is the one that makes env vars uniquely bad for agents.

python
# Your agent has these in its env.
os.environ
# {
#   "OPENAI_API_KEY": "sk-proj-...",
#   "ANTHROPIC_API_KEY": "sk-ant-...",
#   "STRIPE_SECRET_KEY": "sk_live_...",
#   "GITHUB_TOKEN": "ghp_...",
#   "AWS_ACCESS_KEY_ID": "AKIA...",
# }

# Agent decides to run `git status` to figure out the repo state.
subprocess.run(["git", "status"])
# git is now a process with all five keys in its env.

A coding agent does not need your Stripe key. A docs agent does not need your AWS key. But every subprocess gets all of them, because environment is inherited by default.

4. Container layers and image inspection

If you ENV OPENAI_API_KEY=... in a Dockerfile, the value is baked into the image layer. Anyone with docker pull access to your registry can docker history and read it back. If you use --build-arg instead, it lands in the build metadata, which is also queryable.

The workaround is docker run -e OPENAI_API_KEY=$OPENAI_API_KEY ... at runtime. That works, but it just moves the secret from the image to the launch script. The launch script lives in CI. CI logs are searchable.

5. Shell history and screen sharing

export OPENAI_API_KEY=sk-... in your terminal puts the value in ~/.zsh_history. Most people never clean that file. If you ever screen-share, pair-program, or grant remote support access to your machine, anyone watching for ten seconds with cmd+R open can search backwards through your history for API_KEY=.

Same goes for the standard cat .env to debug a config issue on a Zoom call. The frame goes into the recording. The recording sits in someone's cloud drive forever.

6. AI coding assistants reading your .env

This is the new vector. In late 2025 and early 2026, multiple CVEs landed against AI coding assistants for the same root cause: an attacker can include a .cursorrules or .claude/rules.md or similar file in a public repository that, when opened in the assistant, tricks the assistant into exfiltrating local secrets.

The CheckPoint write-up of CVE-2025-59536 and CVE-2026-21852 is the cleanest example. Open a malicious repo in Claude Code, the agent reads your .env, the agent gets prompted to send the contents to an attacker-controlled URL, the agent does it. No exploit chain, no privilege escalation. Just the agent doing what the agent does.

If your secrets are in .env, your agent can read them. If your agent can be prompted to do things, your secrets are exfiltrable.

What env vars actually solve

They solve one problem, well: keeping secrets out of git. That is genuinely valuable. GitGuardian's 2026 State of Secrets Sprawl report counted 28.65 million new secrets exposed in public GitHub commits in 2025 alone, a 34% jump year over year. Most of those were keys someone hard-coded then git pushed. So yes, an env var is better than a literal.

The mistake is treating "better than a git commit" as "good enough for production". It isn't. Especially when the process holding the env var is an agent that can be socially engineered through its prompt.

What's actually better

Three patterns in increasing order of safety.

Pattern A: OS keychain

macOS has security. Linux has secret-tool. Windows has the Credential Manager. All three let you store a secret encrypted at rest, retrieve it on demand, and never write it to a file or env var.

python
import keyring

api_key = keyring.get_password("openai", "default")
client = OpenAI(api_key=api_key)

The key is decrypted when the call runs and returned to the process. Better than env var because it's not in the process environment at large. Worse than the next pattern because the key is still a string in your process memory and inherited by any subprocess that reads it from there.

Pattern B: Secrets vault (HashiCorp Vault, AWS Secrets Manager, Doppler, etc.)

A vault stores the secret server-side and gives your process a short-lived token to fetch it. The token is in the env. The actual secret is not.

python
import hvac
vault = hvac.Client(url="https://vault.example.com", token=os.environ["VAULT_TOKEN"])
secret = vault.secrets.kv.read_secret_version(path="openai/prod")
api_key = secret["data"]["data"]["api_key"]

Better because rotation, audit, and access control are now real things you can do. Worse because (a) the secret is still a string in your process at runtime once fetched, and (b) you've taken on a cloud dependency that costs money and adds a network round-trip to every cold start.

For a team running production services, this is the right tier.

Pattern C: Credential broker (proxy injection)

The agent never sees the secret. Not at startup, not on demand, not in any string. Instead, a local proxy sits between the agent and the API. The agent makes a normal HTTP call. The proxy matches the destination, injects the real Authorization header, and forwards the request.

bash
# The agent's env has only a placeholder.
export OPENAI_API_KEY="authsome-proxy-managed"

# Launch the agent under the broker's proxy.
authsome run -- python agent.py

# Inside agent.py, the OpenAI library does its normal init.
# When it sends a request to api.openai.com, the proxy strips
# the placeholder Authorization header and substitutes the real one.
# The agent process never reads or holds the real key.

If the agent is prompt-injected into reading os.environ and exfiltrating it, the attacker gets OPENAI_API_KEY=authsome-proxy-managed. Useless.

If a stack trace dumps the environment, same thing.

If a subprocess inherits the env, same thing.

If you screen-share a printenv, same thing.

The broker pattern is the only one in this list that defends against agent-class threats, because it accepts the premise that the agent will leak whatever it holds and arranges for the agent to hold nothing worth leaking.

"But my agent needs to authenticate"

The agent doesn't, actually. The agent makes HTTP requests. Those need to authenticate. As long as authentication happens between the agent's request and the destination service, the agent itself can stay credential-free.

This is what every CDN, every API gateway, every reverse proxy already does for inbound traffic. We just haven't been doing it for outbound traffic from agents, because the tooling wasn't there.

Authsome is one tool in this category. Agent Vault, Clawvisor, and OneCLI are others. They differ in where they're deployed (laptop vs. separate host) and what they bundle (OAuth flows, refresh handling, provider definitions), but the core idea is the same: keep the secret out of the agent's process.

A practical migration path

You don't have to rip out all your env vars tomorrow. Pick the highest-blast-radius keys first.

  1. Anything an AI agent reads. Coding assistants, autonomous workflows, anything you've granted shell or read_file access to. Move these first. The blast radius is the highest, and the attack surface is the largest.
  2. Anything in a multi-user container or CI runner. ps auxe, kubectl exec, and CI log capture all see env vars. Reduce them.
  3. Anything with a real money or data-write blast radius. Stripe, AWS, Slack with write scopes, your prod database. These deserve the proxy treatment even for code you wrote yourself.
  4. The long tail. Read-only API keys, hobby project tokens, things behind their own rate limits. Env var is still defensible here. You're not protecting against state actors.

What to push back on

You will hear three objections to moving off env vars. They're each wrong in a specific way.

"Env vars are simple, this is over-engineering." A six-line pip install and a one-line CLI launcher is not over-engineering. The over-engineering is the next time you have to rotate a key across nine repos because it leaked.

"The proxy is a single point of failure." It's a single point of control. Failure mode is localhost:7998 is down, which is the same failure mode as ~/.aws/credentials is unreadable. Local processes do fail, but they don't fail in a way that costs you money or leaks data.

"My agent framework reads os.environ directly." Almost all of them do, and that's fine. The placeholder pattern means the framework reads OPENAI_API_KEY and gets authsome-proxy-managed. The framework hands that to the OpenAI client. The OpenAI client puts it in an Authorization header. The proxy intercepts the header. The framework, the client, and the agent never need to know there was substitution happening.

The shorter version

Environment variables solve the "don't commit your key to git" problem. They don't solve the "your process is now a hostile read primitive" problem, which is what every agent on your machine has become. Move the keys out of the process. Move the secret-handling into a broker. Let the agent stay dumb.

If you want the long version with code and a security model, the Authsome quickstart walks through it in about ten minutes. If you want the academic version with threat models and refresh-token rotation, the Authsome threat model is the right read.

If you remember one thing from this post: the placeholder env var is the new env var. Your agent should never see a real secret, even at startup.

Priyansh Khodiyar

Priyansh Khodiyar

Maintainer

Works on authsome and the agentr.dev tooling.