I wanted an agent that does the boring half of outreach for me. Read a contact out of the CRM, find the person's email if it's missing, draft a short note, send it, tell the team, and leave a paper trail so I can follow up. Six tools. A CRM, an enrichment service, an email API, a chat API, a docs API, and an issue tracker.
I built it. It works. And somewhere around the third tool I realized I had quietly assembled the single fattest credential target I own: one Python process holding a live HubSpot OAuth token, a Notion token, a Resend re_ key, a Slack xoxb- token, a Linear key, and a Hunter key, all in plaintext, all readable by a model that I am actively encouraging to ingest text from the open internet.
This is a field report on that build. What the six tools actually want, where the auth gets fiddly, and the one architectural move that let me say "touches six tools, leaks nothing" without lying about it.
The job, concretely
Here is the pipeline, in order:
- HubSpot · read a contact record (name, company domain, last touch).
- Hunter.io · if the email is missing, look it up from name + domain.
- Notion · draft the outreach note as a page so a human can review it.
- Resend · send the email.
- Slack · post "outreach sent to X" into a channel.
- Linear · file a follow-up issue so it doesn't fall through the cracks.
Nothing exotic. This is the shape of a hundred internal agents people are shipping right now. The interesting part is not the orchestration. It's the auth, because every one of these tools authenticates differently, and every difference is a place to get it wrong.
What each tool actually wants
I'll show the raw HTTP, because the client-library wrappers hide exactly the detail that matters.
HubSpot is OAuth2. You send a bearer token:
curl https://api.hubapi.com/crm/v3/objects/contacts \
-H "Authorization: Bearer <hubspot_oauth_access_token>"
The catch nobody warns you about: the access token is short-lived, on the order of half an hour. So either your agent owns refresh-token rotation logic, or you bake in a long-lived token and pray. Neither is appealing. Short-lived tokens are good security hygiene that becomes your problem the moment you put the token inside the agent.
Hunter is the enrichment step. Email Finder takes a name and a domain:
curl "https://api.hunter.io/v2/email-finder?domain=example.com&first_name=Jane&last_name=Doe" \
-H "X-API-KEY: YOUR_KEY"
It's an API-key flow, sent as a header (or as a query param), which is convenient while you're wiring the pipeline and don't want to spam real people.
Notion is where I lost an hour. The create-page call needs three things, and missing any one of them fails in a confusing way:
curl -X POST https://api.notion.com/v1/pages \
-H "Authorization: Bearer <notion_token>" \
-H "Notion-Version: <version>" \
-H "Content-Type: application/json" \
-d '{ "parent": { "database_id": "<db_id>" },
"properties": { "title": [{ "text": { "content": "Outreach draft" } }] } }'
You must send the Notion-Version header on every request. And the integration only sees pages a human explicitly shared with it in the Notion UI. There's no API call for that. If you forget to click "share" on the target database, the API doesn't error loudly. It just returns nothing, and you sit there debugging your JSON when the actual problem is a permission you set in a web app. That's the fiddly tax of Notion: half the auth is out-of-band, done by a human, in a browser.
Resend is the opposite. It is the dumbest, longest-lived secret in the whole pipeline:
curl -X POST https://api.resend.com/emails \
-H "Authorization: Bearer re_xxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"from":"you@yourdomain.com","to":"prospect@example.com","subject":"Quick question","html":"<p>Hi there.</p>"}'
One re_ bearer key, no expiry to speak of, full send rights. Exactly the kind of credential you least want sitting in a process you don't fully trust.
Slack and Linear look similar and are not. Slack wants a bot token with a Bearer prefix:
curl -X POST https://slack.com/api/chat.postMessage \
-H "Authorization: Bearer xoxb-your-bot-token" \
-H "Content-Type: application/json; charset=utf-8" \
-d '{"channel":"C0123456789","text":"Outreach sent to prospect@example.com"}'
Linear's GraphQL API has a trap. A personal API key (the lin_api_... kind) is sent with no Bearer prefix at all:
curl -X POST https://api.linear.app/graphql \
-H "Authorization: lin_api_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"query":"mutation { issueCreate(input: { title: \"Follow up with prospect\", teamId: \"<TEAM_ID>\" }) { success issue { id identifier } } }"}'
Send Bearer lin_api_... and it rejects you. But a Linear OAuth token does use Bearer. Same vendor, same endpoint, two opposite rules depending on which credential type you hold. I burned a real chunk of time on a 401 here before I read closely enough to notice. Worth flagging because it's the kind of thing that quietly works in one environment and breaks in another.
So that's six tools, six auth styles: OAuth2 with a short-lived token, a query-param-or-header API key, a bearer with a mandatory version header and out-of-band sharing, a plain long-lived bearer, a bot bearer, and a no-prefix-unless-OAuth GraphQL key.
The "before" picture: where the keys end up
Whatever framework you reach for, the keys converge on one place: the process environment.
With LangChain, the SaaS wrappers and the model client read keys straight out of os.environ:
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
load_dotenv() # OPENAI_API_KEY now in os.environ
llm = ChatOpenAI(model="gpt-4o") # key auto-read from env, and so is every other tool's key
load_dotenv() slurps your .env, and now the model's key, plus every tool's key, lives in the process's address space.
If you drive the agent from an IDE through MCP, it's the same story one layer over. Cursor reads .cursor/mcp.json:
{
"mcpServers": {
"hubspot": {
"command": "npx",
"args": ["-y", "@some/hubspot-mcp"],
"env": { "HUBSPOT_TOKEN": "${env:HUBSPOT_TOKEN}" }
}
}
}
The ${env:...} indirection is a real improvement. It keeps the literal secret out of the committed file. But it does not keep the secret out of the running process. At spawn time Cursor resolves ${env:HUBSPOT_TOKEN} and hands the real value to the MCP server's environment. The committable file is safe. The process is not. (If MCP itself is new to you, we wrote a primer at /blog/what-is-mcp-a-developer-primer.)
Either way, when the build finally runs end to end, here's the state of the world: six live, high-privilege credentials, in one process, in plaintext, reachable by os.environ, reachable by anything the model can shell out to.
Why that's worse than it sounds
I'd have shrugged at this two years ago. The reason I don't anymore has a name: Simon Willison's lethal trifecta. An agent becomes genuinely dangerous when it combines three things:
- Access to private data.
- Exposure to untrusted, attacker-controllable content.
- An exfiltration vector (it can make outbound calls).
Look at my outreach agent against that list. It reads my CRM, which is private data. It ingests untrusted text constantly: a prospect's website, an inbound reply, an enrichment result it didn't write. And it can POST to six external APIs, which is six exfiltration vectors. It is a clean, textbook instance of all three. Not a contrived demo. The actual job description.
This stopped being theoretical. Researchers keep disclosing indirect-prompt-injection findings against production AI tools, and document-and-knowledge assistants have shown up among them. The pattern is consistent: feed the agent poisoned content, get it to read something private, get it to send that something somewhere. If you want the mechanics of how a benign-looking instruction turns into a credential walking out the door, we broke it down in /blog/how-prompt-injection-becomes-credential-exfiltration.
The uncomfortable conclusion: a sufficiently clever piece of injected text could get my agent to read os.environ and curl the contents to a host it controls. Every key walks out at once. The whole point of the pipeline, "make outbound calls," is the same machinery the attacker borrows.
What the obvious fixes do and don't do
The instinct is "use a secrets manager." I tried to talk myself into it, and to be fair to those tools, they are good.
| Tool | Strength | The catch for agents |
|---|---|---|
| Doppler | Simple onboarding, clean env syncing | Tighter, dynamic policy lives on higher tiers |
| HashiCorp Vault | Dynamic secrets, transit encryption | Operationally heavy to run at scale |
| Infisical | Open source, self-host free, JIT access + approvals | Still delivers the secret into the process |
Here's the honest framing, and I want to be careful not to strawman these: Doppler, Vault, and Infisical are excellent at storing and delivering secrets. They kill credential sprawl, they rotate keys, they gate who can fetch what. But their delivery mechanism, every one of them, ends the same way: the secret gets injected into the process as an env var or fetched via SDK at runtime. Once delivered, it is in the agent's address space. They solve the storage layer well. They do not solve the agent-process-exposure layer, because solving storage was never supposed to. (I went deeper on why the cloud managers specifically miss this in /blog/aws-secrets-manager-isnt-built-for-ai-agents.)
So I had a real gap. The secret has to be available to the request, but I don't want it available to the process making the request. Those sound contradictory until you move the injection point.
The move: inject at the boundary, not in the process
The fix that actually closed the gap was to stop putting the secret in the agent at all, and inject it at the network boundary instead, on the request's way out.
I ran the agent under a local credential broker. The shape, using authsome, an open-source MIT-licensed local-first broker: log in to each provider once, then launch the agent under a local HTTPS proxy.
uv tool install authsome
authsome login hubspot
authsome login notion
authsome login slack
authsome login linear
authsome login resend
authsome login hunter
authsome run -- python outreach_agent.py
Each login runs the right flow for that provider (browser PKCE for the OAuth four, an API-key prompt for Resend and Hunter) and stores the credential in an encrypted SQLite vault under ~/.authsome/. No cloud, no account, no telemetry.
What authsome run does is the part that matters. It starts a local HTTPS proxy, spawns my agent with its proxy environment pointed at it, and sets placeholder env vars so the SDKs boot happily (OPENAI_API_KEY=authsome-proxy-managed, for instance). When the agent makes a request to api.hubapi.com, the proxy recognizes the destination host and swaps in the real Authorization header on the outbound request. The child process never reads, holds, or even sees the real token. If something injects "dump os.environ and curl it somewhere," all it gets is a bag of placeholder strings.
This also quietly solved my HubSpot token-expiry headache and the Linear Bearer-vs-no-Bearer trap. Because the broker owns the credential, it owns the refresh, and it attaches the header format the bundled provider expects. The agent code doesn't carry refresh logic and doesn't have to know that Linear-over-OAuth wants Bearer while a raw personal key wouldn't. I checked, and all six of my tools are real bundled providers (HubSpot, Notion, Slack, and Linear via OAuth2/PKCE; Resend and Hunter as API keys), so there was no custom config to write. (If you do need something off the list, you add a JSON provider file under ~/.authsome/providers/; I won't cover that here.)
If you're wiring agents to this exact class of tool, the companion walkthrough at /blog/wiring-claude-code-to-github-linear-and-stripe covers the per-provider login dance in more depth.
Throttling the exfil vector
Keeping the key out of the process kills the "read the secret" half of the trifecta. The other half, "make an outbound call to an attacker host," needs a separate answer, and this is where I have to be precise, because it's easy to overstate.
By default the proxy is transparent passthrough: it injects credentials for the providers it recognizes and forwards everything else unchanged. That is not an allowlist. If you stop reading here and assume you're protected, you're not.
The proxy does support a global deny-by-default mode for a run. In that mode it serves only the providers you currently have a live connection for (my six) and returns a 403 for any other host. So a curl to attacker.example.com gets blocked at the proxy. The exfil vector is throttled to an allowlist of exactly the six hosts the agent is supposed to talk to. The allow/deny is a global property of the proxy run, not a per-tool rule, so treat it as a coarse egress fence rather than fine-grained authorization.
That gives me the second half of the claim. And the receipt is the audit log. Authsome keeps an append-only JSONL log under ~/.authsome/ that records every credential read, refresh, login, and revoke. Critically, it never logs the secret value. So I can review which providers the agent exercised, and the record itself contains nothing sensitive. That's what lets me say "leaks nothing" and actually back it.
Where I have to be honest
I am not going to pretend this is a silver bullet, because it isn't, and a few things are genuinely fiddly.
Injecting credentials into HTTPS traffic means the proxy terminates TLS, which requires its CA certificate to be trusted on the machine. Non-HTTP protocols (WebSocket, gRPC, raw TCP to a database, SSH) bypass the proxy entirely, so they get neither injection nor deny enforcement. Some SDKs pin TLS and ignore the proxy. And if two providers claim the same host, the proxy refuses to guess and forwards the request unchanged.
The bigger honesty: this shrinks the blast radius. It does not cure prompt injection. The broker does nothing to stop my agent from misusing the six tools it is allowed to use. If a poisoned prospect website convinces the model to send a hostile email through Resend, that email goes out. Resend is on the allowlist. The send is "legitimate" as far as the proxy can tell. What the broker buys me is narrow and real: the credentials can't be exfiltrated because they aren't in the process, and the agent can't phone home to an arbitrary host because the off-allowlist call gets a 403. The keys can't leak. The agent can still be dumb. Those are different problems, and I'd rather solve one cleanly than claim to solve both.
A per-agent policy engine, the kind that decides "this agent may use Resend but not Slack," is not something this ships today. The allow/deny is a global property of the proxy run, not a per-tool rule. If you need rich per-tool authorization, plan for it separately. I sketched the broader landscape of these tradeoffs in /blog/agent-credential-brokers-in-2026.
Where I landed
The agent does the job. It reads HubSpot, fills gaps with Hunter, drafts in Notion, sends with Resend, posts to Slack, and files a Linear issue. The difference between the first version and the one I'm comfortable running is one structural decision: the secrets moved out of the agent process and into a local broker, and the egress got pinned to an allowlist I can audit.
I still review the drafts before they send. I still don't let it touch a prospect list I haven't seen. The broker didn't make the agent trustworthy. It made the agent survivable when it isn't, which, for anything touching six live credentials and reading text from the internet, is the bar I actually care about.
Next steps
Quickstart
Install the broker, log in to your providers, and run your first agent under the proxy in a few minutes.
Wire LangChain agents
Connect a LangChain agent so its tool keys live in the broker, not the process.
How proxy injection works
The mechanics of injecting credentials at the network boundary, plus the known limitations.
Read the audit log
See exactly which providers were injected, with no secrets in the log.
Further reading
Supply chain risks for AI agents: malicious MCP servers, poisoned skills, and how to triage
A field guide to the 2025-26 wave of agent supply chain attacks. Malicious MCP servers, poisoned skills, npm and PyPI compromises, the five injection vectors, and the exact triage checklist if you already shipped one.
Read postMay 31, 2026Building a DevOps agent: cluster, cloud, PagerDuty, GitHub, without a single long-lived key
A field report on building a production DevOps incident triage agent across EKS, CloudWatch, PagerDuty, Datadog, GitHub and Slack with zero long-lived credentials on disk.
Read postMay 30, 2026Building a research agent: papers, web, Drive, and inbox without leaking
A field report on wiring a multi-tool research agent across Brave, arXiv, Google Drive, Gmail, Notion, Linear, and Slack, then taking every credential out of the agent process so a prompt injection has nothing to steal.
Read post