DEV Community

Jeff
Jeff

Posted on Originally published at powerduck.com

Store the Upstream Token in the Gateway, Not in Your MCP Config

When we first wired billing into the agent, the config looked like this:

{
  "mcpServers": {
    "billing": {
      "command": "npx",
      "args": ["-y", "@powerduck/openapi-mcp-server", "serve", "--transport", "stdio"],
      "env": {
        "BILLING_UPSTREAM_TOKEN": "sk_live_9f2c..."
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

We thought we were being careful. The token lived in a local JSON file. It was not in a git repo. It was just... on every engineer's laptop that wanted to use the agent.

Six of us had that file. One of us had a laptop that ran a backup sync to an external drive. Another ran a dotfiles repo that she sometimes pushed to a personal GitHub. The token was one grep -r sk_live away from being anywhere.

Worse: the token was a live billing token. It could call POST /refunds, not just GET /invoices. The agent's tool list was read-only, but the credential it was using had write scope. We had layered a safety net on top of an unsafe identity.

The two-token model

There are two credentials in an MCP setup, and they want opposite homes:

The upstream credential talks to your real API. It knows how to authenticate to billing, the CRM, whatever. It has real scope. It should live somewhere you control, can rotate, and can revoke centrally. It should not live on an engineer's laptop.

The access credential talks to the MCP endpoint itself. It says "this client is allowed to ask the gateway to call billing on its behalf." It has narrow scope: it can call the exposed tools, it cannot call billing directly, and you can issue a separate one per teammate.

When you paste the upstream token into ~/.cursor/mcp.json, you have collapsed those two into one. The agent client now holds a credential that reaches past the gateway and straight into your production service.

What changes when the gateway holds the upstream token

The config on the engineer's laptop shrinks to:

{
  "mcpServers": {
    "billing": {
      "type": "http",
      "url": "https://mcp.powerduck.com/d/abc123",
      "headers": {
        "Authorization": "Bearer pk_eng_maria_..."
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

pk_eng_maria_... is a per-teammate access key scoped to the exposed read-only tools. If Maria's laptop is lost or she leaves, you revoke that one key. The live billing token never touched her disk. If it leaks, you have a real incident; if the access key leaks, the blast radius is "the attacker can call the 9 read-only tools on a demo org."

Rotation stops being a company-wide announcement. The upstream token rotates in the gateway's config on whatever schedule billing already uses. No engineer gets an email, no mcp.json edit, no "did you update yours?" thread. The next request after rotation just works.

Logs get cleaner too. When the gateway holds the token, the request log can redact the Authorization header before it hits the console. When the token is on the laptop, the same header shows up in the agent's transcript, in your terminal scrollback, and in any error report the agent decides to paste back to you.

The migration that took an afternoon

We did this in one sitting:

  1. Generated a new read-scoped token in billing (the old write-scoped one was already rotated out).
  2. Pasted it into the gateway's upstream-auth config for the billing document.
  3. Issued one per-teammate access key, sent it in a 1Password shared item.
  4. Asked everyone to replace their mcp.json block with the URL + access key version.
  5. Deleted the old BILLING_UPSTREAM_TOKEN env var from every local config we could find.

The agent did not notice. The tools returned the same shapes. The difference was that a year later, when Maria's laptop was stolen on a train, the incident response was "revoke pk_eng_maria_" and nothing else.

The rule of thumb

If a credential in your MCP config can reach a production service without going through the gateway, it is in the wrong place. The client config should describe where to call, not how to authenticate to the upstream service. The gateway is the only thing that ever needs to know the upstream token. Everything else gets a narrow, revocable, per-user key.

It feels like a small distinction. It is the difference between "rotate a secret for 6 laptops" and "rotate a secret in one config screen."

This is the model Powerduck uses: the upstream Bearer/Basic/APIKey is stored in the gateway, not in Cursor's MCP config, so rotating a token does not mean re-editing every teammate's client.

Top comments (1)

Collapse
 
launchgatecheck profile image
Launch Gate •

"A safety net on top of an unsafe identity" is the sentence - the tool list said read-only while the credential said POST /refunds, and nothing in the system compared the two. That's pinnable: most providers expose a token introspection or permissions endpoint, so a tiny nightly job can fetch the live credential's actual scopes and diff them against the gateway's exposed tool list. Credential scope wider than tool scope is a failing test, not a wiki page. The same drift runs the other way too - someone widens the token to unblock one urgent call and the read-only tool list never finds out. The laptop-side pin is blunter but catches the original sin: a canary token that pages you if it's ever used, plus sk_live in the pre-commit secret scan so the config file can't come back through a dotfiles repo. Neither check is deep. Both fail loudly, which is the entire job of a guardrail at this layer.