<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Rojaneer</title>
    <description>The latest articles on DEV Community by Rojaneer (@rojaneerdev).</description>
    <link>https://dev.to/rojaneerdev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4145316%2F4b26832a-0172-4456-bef6-c8c07a5461bc.png</url>
      <title>DEV Community: Rojaneer</title>
      <link>https://dev.to/rojaneerdev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rojaneerdev"/>
    <language>en</language>
    <item>
      <title>Keep your API keys out of your AI agent: a credential pattern for MCP servers</title>
      <dc:creator>Rojaneer</dc:creator>
      <pubDate>Sun, 27 Sep 2026 11:08:31 +0000</pubDate>
      <link>https://dev.to/rojaneerdev/keep-your-api-keys-out-of-your-ai-agent-a-credential-pattern-for-mcp-servers-4cmo</link>
      <guid>https://dev.to/rojaneerdev/keep-your-api-keys-out-of-your-ai-agent-a-credential-pattern-for-mcp-servers-4cmo</guid>
      <description>&lt;p&gt;If you've wired an MCP server into an agent, you've probably done something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"billing"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"billing-mcp"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"BILLING_API_KEY"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sk-live-..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works. It's also handing your live billing key to the least trustworthy process in the system.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;An agent is a program that decides what to do at runtime based on text it was given — some of which comes from the outside world (a webpage it read, a document it summarized, a tool result). That's the whole point, and it's also why the agent process is the wrong place to keep a secret.&lt;/p&gt;

&lt;p&gt;Two things go wrong with the config above:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The credential lives in the agent's environment.&lt;/strong&gt; If the agent is compromised — prompt injection, a poisoned dependency, a tool that returns a malicious payload — the attacker is now one &lt;code&gt;os.environ&lt;/code&gt; read away from your billing key. The blast radius of "the agent did something dumb" includes "the agent's keys are gone."&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The agent can call everything, and you can't prove what it did.&lt;/strong&gt; The MCP server exposes a set of tools; the agent can call any of them. When something goes wrong, your evidence is scattered across logs that the agent itself could have influenced.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You can't fix this by making the agent more careful. The agent is the untrusted part. You fix it by moving the trust boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern: put a gateway between the agent and the MCP server
&lt;/h2&gt;

&lt;p&gt;Instead of letting the agent talk to the MCP server directly, put a small trusted process — a gateway — in the middle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;agent  ──►  gateway  ──►  MCP server
           (holds the       (needs the
            credential)      credential)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;credential lives in the gateway's environment&lt;/strong&gt;, not the agent's.&lt;/li&gt;
&lt;li&gt;The agent gets a &lt;strong&gt;short-lived token scoped to its current run&lt;/strong&gt;, and a URL that only reaches the MCP server &lt;em&gt;through&lt;/em&gt; the gateway.&lt;/li&gt;
&lt;li&gt;On each tool call, the gateway &lt;strong&gt;injects the real credential&lt;/strong&gt; just before forwarding upstream, and strips it from anything it hands back.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The agent is &lt;em&gt;starved&lt;/em&gt; of credentials. It can make tool calls, but it never possesses the secret that authorizes them. Compromise the agent and you get a revocable, per-run token — not the billing key.&lt;/p&gt;

&lt;p&gt;This is more than a reverse proxy, because the gateway is a policy decision point. Since every call goes through it, it can also:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;enforce &lt;strong&gt;which tools&lt;/strong&gt; each agent may call (default-deny),&lt;/li&gt;
&lt;li&gt;enforce &lt;strong&gt;read-only&lt;/strong&gt; access where you want it,&lt;/li&gt;
&lt;li&gt;and &lt;strong&gt;record every call and every refusal&lt;/strong&gt; in one place the agent can't rewrite.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A worked example
&lt;/h2&gt;

&lt;p&gt;Here's the pattern implemented with &lt;a href="https://github.com/agenthof/agenthof" rel="noopener noreferrer"&gt;Agenthof&lt;/a&gt;, an open-source (Apache-2.0, Go) governance gateway. The config is the useful part; the tool is just one way to run it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Register the MCP server with the gateway — this is where the secret lives:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ticket-search&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mcp&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://tickets.internal/mcp&lt;/span&gt;
    &lt;span class="na"&gt;credential_source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;static_env&lt;/span&gt;
    &lt;span class="na"&gt;token_env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TICKETS_MCP_TOKEN&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;TICKETS_MCP_TOKEN&lt;/code&gt; is read from the &lt;em&gt;gateway's&lt;/em&gt; environment. The agent process never sees it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Grant each agent only the tools it needs — default-deny:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;legacy-triage&lt;/span&gt;
&lt;span class="na"&gt;execution&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;fronted&lt;/span&gt;
&lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://legacy.internal/agents/triage&lt;/span&gt;
&lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ticket-search&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;                            &lt;span class="c1"&gt;# every tool ticket-search exposes&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;billing-mcp&lt;/span&gt;
    &lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;get_invoice&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;list_invoices&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# only these two of billing-mcp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An agent gets access to a resource only if it's named here, and to the &lt;em&gt;tools&lt;/em&gt; it names (or an explicit &lt;code&gt;mode: all&lt;/code&gt;). Leaving the list off isn't "allow everything" — it's rejected at config load. Widest access is always something you typed on purpose, never something you got by omission.&lt;/p&gt;

&lt;p&gt;Want read-only? Say so, and the operator's classification decides what counts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;billing-mcp&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read-only&lt;/span&gt;        &lt;span class="c1"&gt;# only the tools billing-mcp is declared to expose read-only&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. For OAuth-protected servers, even the client secret stays out of the agent.&lt;/strong&gt; The gateway's broker mints a token from your identity provider per call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;billing-mcp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mcp&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://billing.internal/mcp&lt;/span&gt;
    &lt;span class="na"&gt;credential_source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;static_env&lt;/span&gt;
    &lt;span class="na"&gt;grant_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;client_credentials&lt;/span&gt;
    &lt;span class="na"&gt;issuer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://idp.example.com/&lt;/span&gt;
    &lt;span class="na"&gt;token_endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://idp.example.com/oauth2/token&lt;/span&gt;
    &lt;span class="na"&gt;client_id_env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;BILLING_MCP_CLIENT_ID&lt;/span&gt;
    &lt;span class="na"&gt;client_secret_env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;BILLING_MCP_CLIENT_SECRET&lt;/span&gt;
    &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;billing.read&lt;/span&gt;
    &lt;span class="na"&gt;read_only_tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;get_invoice&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;list_invoices&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client id and secret are read from the gateway's environment; the agent gets the minted access token's &lt;em&gt;effects&lt;/em&gt;, never the token, and never the secret that mints it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Because everything flows through one point, every tool call and every refusal lands in a hash-chained, tamper-evident audit ledger&lt;/strong&gt; — so "what did this agent actually do?" has a single, ordered answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does &lt;em&gt;not&lt;/em&gt; do
&lt;/h2&gt;

&lt;p&gt;Being honest about the boundary is the whole point, so:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;This governs and audits. It does not &lt;em&gt;contain&lt;/em&gt; the agent.&lt;/strong&gt; A compromised agent process still runs on some host; if that host has no sandbox, the process can still do local damage or reach the network directly. Keeping credentials out of the agent shrinks the blast radius — it is not a jail. Containment is your sandbox's job; this is the keys and the CCTV, not the locked room.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The ledger is tamper-&lt;em&gt;evident&lt;/em&gt;, not tamper-&lt;em&gt;proof&lt;/em&gt;.&lt;/strong&gt; Hash-chaining means edits are detectable after the fact. It does not stop an attacker with enough privilege from rewriting history — it makes the rewrite show up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those aren't weaknesses to paper over; they're where the boundary actually sits, and knowing that is how you deploy the pattern correctly (gateway on a trusted host, agent in a sandbox, ledger shipped somewhere append-only).&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Regardless of which tool you use, the pattern is portable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Secrets live in a trusted gateway, never in the agent.&lt;/strong&gt; The agent gets a scoped, revocable, per-run token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Default-deny tool access.&lt;/strong&gt; An agent reaches a tool only because you named it, and read-only where read-only is enough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log every call and refusal&lt;/strong&gt; somewhere the agent can't edit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The agent stays the flexible, fallible thing it's supposed to be — and it stops being the thing that holds your keys.&lt;/p&gt;

&lt;p&gt;A working implementation of all of the above is at &lt;a href="https://github.com/agenthof/agenthof" rel="noopener noreferrer"&gt;github.com/agenthof/agenthof&lt;/a&gt;. If you're doing this differently — a sidecar, a service mesh, provider-side scoping — I'd genuinely like to hear how it's holding up.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>security</category>
      <category>go</category>
    </item>
  </channel>
</rss>
