Securing the OpenAI Agents SDK end to end

The OpenAI Agents SDK reads OPENAI_API_KEY from your process environment and gives tools direct access to whatever you hand them. Here is how to lock that down end to end in 2026.

May 29, 202613 min read

Securing the OpenAI Agents SDK end to end.

You have a working agent built on the OpenAI Agents SDK. It calls a few tools. One tool refunds a Stripe charge, one posts to Slack, one opens a GitHub PR. Your .env has OPENAI_API_KEY, STRIPE_API_KEY, SLACK_BOT_TOKEN, and GITHUB_TOKEN. The SDK reads OPENAI_API_KEY from os.environ, your tool functions read theirs the same way, everything works, you ship.

Then you read the 2025 and 2026 horror stories. A malicious GitHub issue, processed by a perfectly innocent "list open issues" tool, exfiltrates private repo contents through the agent's own PAT (Invariant Labs, May 2025). A typo-squatted MCP package on the registry silently reads values from os.environ on install. mcp-remote, a widely used MCP client, received a critical CVSS 9.6 RCE because it pasted an attacker-controlled URL into a shell (CVE-2025-6514, JFrog writeup).

And your agent process holds, in plaintext, in os.environ, every API key it has ever been given.

This post walks through what the OpenAI Agents SDK actually does with credentials in 2026, where the SDK gives you primitives to do better, where it doesn't, and the placeholder-and-broker pattern that gets the raw secret out of the agent's address space entirely. Python and TypeScript both.

What the Agents SDK actually does with your keys

Before locking anything down, it's worth being precise about what the SDK reads and when, because most "best practice" posts are vague enough to mislead.

Python: openai-agents

The Python SDK lazily reads OPENAI_API_KEY from os.environ the first time anything constructs an OpenAI client. There is no eager validation, no startup probe. The simplest possible program looks like this and works:

bash
export OPENAI_API_KEY=sk-proj-...
pip install openai-agents
python
from agents import Agent, Runner

agent = Agent(name="Assistant", instructions="You are a helpful assistant")
result = Runner.run_sync(agent, "Write a haiku about recursion in programming.")
print(result.final_output)

The SDK reads more than just OPENAI_API_KEY. The official config page documents the full set, including OPENAI_BASE_URL, OPENAI_ORG_ID, OPENAI_PROJECT_ID, OPENAI_AGENTS_DISABLE_TRACING, OPENAI_AGENTS_DONT_LOG_MODEL_DATA, OPENAI_AGENTS_DONT_LOG_TOOL_DATA, and OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA.

The important property is the boring one. Once OPENAI_API_KEY is in os.environ, it is in the address space of every line of Python code that runs in that process. Your dependencies, your dependencies' dependencies, every tool function, every callback. os.environ.get("OPENAI_API_KEY") works from anywhere.

TypeScript: @openai/agents

The TypeScript SDK behaves the same way. It resolves OPENAI_API_KEY lazily on first client creation. If you cannot set the env var, you can call setDefaultOpenAIKey() programmatically, and setTracingExportApiKey() for a separate tracing key (JS config guide).

bash
npm i @openai/agents
ts
import { setDefaultOpenAIKey, setTracingExportApiKey } from '@openai/agents';

setDefaultOpenAIKey(process.env.OPENAI_API_KEY!);
setTracingExportApiKey(process.env.OPENAI_TRACING_KEY!);

The same address-space property applies. process.env.OPENAI_API_KEY is readable from any module loaded into the Node process.

Verify it yourself

The fastest way to internalize the threat is to run this inside any agent process you have, after the agent has started:

python
import os
# any tool, any transitive import, any malicious dep gets this for free
for k, v in os.environ.items():
    if any(s in k.upper() for s in ("KEY", "TOKEN", "SECRET", "PASSWORD")):
        print(k, "=", v[:8] + "...")

If that prints your live keys, a prompt-injected tool output or a trojaned transitive dependency can do exactly the same thing and ship them to a webhook.

The threat model is not theoretical

If you have shipped agents in the last year, you already know this list. If you haven't, here is the floor.

  • CVE-2025-6514 (mcp-remote, CVSS 9.6, 2025). A malicious MCP server could return a crafted authorization_endpoint URL that the client passed unsanitized to the system shell. Documented impact includes stealing API keys, cloud credentials, local files, SSH keys, and Git repo contents (JFrog writeup).
  • GitHub MCP prompt injection (Invariant Labs, May 2025). A public issue contained hidden instructions. An agent asked to "check open issues" read the issue, was prompt-injected into using the same PAT to read private repos, and published their contents into a public PR. Root cause: blanket-scoped PATs.
  • Tool poisoning and supply-chain MCP packages. Public writeups have documented MCP servers poisoned through malicious tool descriptions to exfiltrate chat history, and impersonation packages uploaded to MCP registries that silently exfiltrate env vars from clients that install them.
  • Attacker-controlled HTML caused unauthorized modification of local MCP configuration in at least one popular client in 2026, leading to registration of a malicious STDIO MCP server and command execution with limited user interaction. Check vendor advisories for your client of choice.

The common pattern across every single one of these. The credential was in the process, the attacker found a way to run code (or persuade the model to run a tool) inside that process, the credential left. For a longer breakdown see how prompt injection becomes credential exfiltration.

The Agents SDK does not cause any of these vulnerabilities. It also does nothing to mitigate the credential-exfiltration step.

Step 1: stop reading the key from .env

The first concrete improvement is to stop putting OPENAI_API_KEY in the process environment at all. The SDK ships a documented programmatic override for exactly this reason.

In Python:

python
from agents import set_default_openai_key, set_tracing_export_api_key

# fetch from your vault/broker at startup, not from os.environ
set_default_openai_key(fetch_secret("openai/runtime"), use_for_tracing=False)
set_tracing_export_api_key(fetch_secret("openai/tracing"))

If you need full client control (custom base URL, timeouts, headers):

python
from openai import AsyncOpenAI
from agents import set_default_openai_client

custom_client = AsyncOpenAI(
    api_key=fetch_secret("openai/runtime"),
    base_url="https://your-gateway.example.com/v1",
)
set_default_openai_client(custom_client, use_for_tracing=False)

Sources: set_default_openai_key / set_default_openai_client in the config docs.

In TypeScript:

ts
import {
  setDefaultOpenAIKey,
  setTracingExportApiKey,
} from '@openai/agents';

setDefaultOpenAIKey(await fetchSecret('openai/runtime'));
setTracingExportApiKey(await fetchSecret('openai/tracing'));

Source: setDefaultOpenAIKey reference.

This is strictly better than .env because the key only lives in the variable you pass and in whatever the SDK stores internally on the client. It is not yet good. fetch_secret(...) still returns a plaintext string into your Python process, the OpenAI client still holds it, and a malicious in-process attacker can still read it from either place. We will fix that in step 3.

Note

The use_for_tracing flag matters. The Agents SDK has its own tracing pipeline that, by default, will use the runtime key to upload traces unless you give it a separate one. If your runtime key has higher privilege than you want trace exports to have, set use_for_tracing=False and call set_tracing_export_api_key with a scoped key.

Step 2: handle per-tool credentials correctly

The SDK has no built-in per-tool secret store. Tools defined with @function_tool receive a RunContextWrapper[T], and you decide what T is. The temptation is to dump every credential into it. The Agents SDK context docs explicitly warn against putting secrets in RunContextWrapper.context if you intend to persist or transmit serialized state.

This warning matters because RunState is serializable. If you use the SDK's human-in-the-loop pattern, or any durable execution model where the run gets suspended and rehydrated later, the context is part of what gets persisted. Plaintext keys in context end up in your queue, your database, your logs.

The least-bad version of the SDK-blessed pattern looks like this:

python
from dataclasses import dataclass
from agents import Agent, Runner, RunContextWrapper, function_tool

@dataclass
class ToolCreds:
    stripe_key: str
    slack_token: str

@function_tool
async def refund(wrapper: RunContextWrapper[ToolCreds], charge_id: str) -> str:
    # secret never enters the LLM context, only the tool's Python side
    return stripe_client(api_key=wrapper.context.stripe_key).refund(charge_id)

agent = Agent[ToolCreds](name="Ops", tools=[refund])
Runner.run_sync(
    agent,
    "refund ch_123",
    context=ToolCreds(stripe_key=..., slack_token=...),
)

That keeps the secret out of the model's input. It does not keep it out of the Python process. Any line of code that has a reference to wrapper (or that calls os.environ if you fall back to that) can read it. If you serialize the run, it goes with it.

The honest fix is to not put the literal secret in context at all. Put a placeholder, a reference, or a callable that fetches at use time:

python
@dataclass
class ToolCreds:
    fetch_stripe: callable   # returns the key only when called
    fetch_slack: callable

@function_tool
async def refund(wrapper: RunContextWrapper[ToolCreds], charge_id: str) -> str:
    return stripe_client(api_key=wrapper.context.fetch_stripe()).refund(charge_id)

Better still, do not hand the tool a key at all. Hand it a client that has been pre-configured to route through a local proxy, and let the proxy be the one place that ever sees the real credential. That's step 3.

For the broader pattern, see stop putting API keys in environment variables.

Step 3: the placeholder + broker pattern

Steps 1 and 2 reduce the surface area but never get below "the secret is somewhere in the Python process." If you want to defeat in-process credential exfiltration, the secret has to leave the process.

The mechanical pattern, vendor-agnostic, is:

  1. The agent process holds only placeholders in env vars and config (e.g. OPENAI_API_KEY=broker-managed).
  2. The agent makes its outbound HTTPS calls to OpenAI, Stripe, Slack, GitHub, etc. as normal.
  3. A local proxy running on 127.0.0.1 intercepts those calls (typically via HTTPS_PROXY plus a trusted local CA the proxy generates on first run).
  4. The proxy matches the destination, looks up the real credential in an encrypted local vault, swaps in the real Authorization header on the outbound request, and forwards.
  5. The agent never reads, holds, or logs the real key.

A prompt-injected tool that reads os.environ finds the placeholder. A malicious dep that introspects RunContextWrapper.context finds a callable that, if invoked, also just gets the placeholder. The real credential lives in the broker's encrypted vault, never in the agent's address space.

You can build this yourself with mitmproxy and a keyring. Or you can use an off-the-shelf local-first broker. Authsome is the one I use. It is MIT-licensed, the vault is encrypted SQLite under ~/.authsome/, and there is no cloud, no account, and no telemetry. Other open-source options exist; the pattern matters more than the brand.

End-to-end the wiring looks like this:

bash
# one-time install
uv tool install authsome
# or: pip install authsome

# one-time per provider, opens a browser for OAuth or prompts for an API key
authsome login openai
authsome login slack
authsome login github

# run your agent through the proxy. env now contains only placeholders.
authsome run -- python my_agent.py

Inside my_agent.py, you do not call set_default_openai_key at all. The SDK reads OPENAI_API_KEY from env, sees the placeholder, builds a client with it, makes a request to api.openai.com, the local proxy matches the host and swaps in the real key on the way out. Same for any tool that hits Slack or GitHub. Stripe and Anthropic are not bundled providers in Authsome, so you would register them with a small custom provider JSON in ~/.authsome/providers/, but the mechanics are identical.

For tools that genuinely need to read the credential in-process (some SDKs do not respect HTTPS_PROXY, some do their own TLS pinning), there is a library mode that reads from the same vault without the proxy:

python
from authsome.context import AuthsomeContext

creds = AuthsomeContext()
slack_token = creds.get("slack")   # never touches env

Same vault, same audit log. The credential still enters the process at that moment, so this is a fallback for tools that cannot be proxied, not a default.

Warning

A credential broker prevents credential exfiltration. It does not prevent prompt injection, RCE, or tool poisoning. If a malicious tool successfully calls Stripe through your agent, the broker will dutifully inject the real Stripe key and the refund will go through. Scope your tokens. Use approval flows for destructive actions. Treat the broker as one layer.

Step 4: shut down trace-side leakage

The Agents SDK has exactly one built-in "stop leaking secrets" mechanism: the trace-redaction knobs. By default, tool inputs, tool outputs, and model inputs/outputs are included in trace payloads that get exported. If any tool ever passes a token or PII through its arguments or return value, it ends up in traces. Turn it off.

bash
export OPENAI_AGENTS_DONT_LOG_TOOL_DATA=1
export OPENAI_AGENTS_DONT_LOG_MODEL_DATA=1

Or per run:

python
from agents import Runner, RunConfig

Runner.run_sync(
    agent,
    prompt,
    run_config=RunConfig(trace_include_sensitive_data=False),
)

Source: tracing config and the tracing guide.

The JS SDK's config page is the place to check for the equivalent flag; behaviour may differ from Python. Verify before assuming parity.

If you do want sensitive data in traces for debugging, ship those traces to a private exporter, not the default. And use a separate, scoped key via set_tracing_export_api_key so a compromised tracing pipeline does not also have your runtime key.

Step 5: audit, rotate, and scope

The last layer is operational. Two questions to be able to answer.

  1. What was used, by whom, and when? Append-only credential-access logs. Authsome writes a JSONL log of every read, refresh, login, and revoke under ~/.authsome/. You can grep it after a suspicious run.
  2. How do I rotate? authsome rekey rotates the vault master key. Per-provider OAuth tokens refresh automatically. For API keys, authsome login <provider> again replaces the stored value; nothing else changes in the agent.

Scoping is the layer the Agents SDK does not help with at all, and it is the lesson from the GitHub MCP incident. Do not ship a single GitHub PAT with repo, workflow, and admin:org. Create per-purpose tokens with the smallest possible scope. For OAuth providers, use scoped install flows. For multi-account workflows (work GitHub vs personal), Authsome supports named connections:

bash
authsome login github --connection work
authsome login github --connection personal
authsome set-default github work

A practical checklist

For an OpenAI Agents SDK app going into production in 2026, here is what I want to see before signing off:

LayerDefault (insecure)Verified pattern
OPENAI_API_KEY.env loaded into os.environPlaceholder in env, real key injected by local broker, or set_default_openai_key from a vault fetcher
Per-tool secretsos.environ["STRIPE_API_KEY"] inside the toolProxy-injected at the network boundary, or callable in RunContextWrapper.context that fetches at use time
Trace payloadsDefault (tool/model data included)OPENAI_AGENTS_DONT_LOG_TOOL_DATA=1 and OPENAI_AGENTS_DONT_LOG_MODEL_DATA=1, scoped tracing key
Run statePlaintext creds in context get serializedNo plaintext in context, only placeholders or fetchers
Token scopeOne PAT with full scope per providerPer-purpose tokens, smallest scope, per-account connection
AuditNoneAppend-only log of every credential use
RotationManual .env edits, restart everythingauthsome rekey and authsome login swap in place

None of this is exotic. It is the same boring discipline that has always applied to secrets, with one new property added. The agent process is hostile-by-assumption, because the model that drives it can be talked into running attacker-chosen tool calls. Design for that and your incident response stops involving Stripe.

Closing

The Agents SDK gives you two correct primitives. set_default_openai_key for the model key, and RunContextWrapper.context for per-tool credentials. Both still assume your process holds the raw secret in memory. The docs even warn you about the second one. In 2026, with credential-exfiltrating supply-chain attacks, critical RCEs in widely deployed clients (CVE-2025-6514), and prompt-injection-to-PR-leak chains (Invariant Labs) on the record, "the secret is in the process" is the breach.

The fix is not magic. Take the keys out of os.environ. Put placeholders in their place. Let a local broker inject the real value at the network boundary. Turn off trace data. Scope your tokens. Keep an audit log. The Agents SDK supports all of this; you just have to wire it.

Priyansh Khodiyar

Priyansh Khodiyar

Maintainer

Works on authsome and the agentr.dev tooling.