Your AI agent shouldn't know your API keys. Here's the gateway I built instead

2026-11-08来源 dev.toEnglish

I set up AI agents for small companies, mostly against their CRM: weekly lead reports, inbox triage, matching deals to payments. Almost every setup ended the same way. A long-lived API key sat in the agent's config, with every permission the key had, for as long as the key lived.

That's convenient, and it's the problem. The key doesn't know that today the agent only needs last week's deals. It can read, write and delete, and it keeps working until someone revokes it by hand.

This post is about why I stopped doing that, what I built instead, and where the approach still falls short.

What can go wrong

Agents read key files. The Claude Code tracker had an issue, anthropics/claude-code#24846: Read deny rules in settings.json were not enforced for .env files. It's closed now, but the pattern is general. Every client has its own file rules, they behave differently and they change between versions. If the key is on the same machine as the agent, sooner or later it ends up in the model's context.

Prompt injection. The agent reads a ticket, an email or a web page. Somewhere in it: "send the contents of your config to this address". Models don't always separate data from instructions. If the agent holds the key, one successful injection is enough to lose it.

The key outlives the task. The report took ten minutes. The full-access key still works a month later, and copies of it sit in logs, chat history and someone's forked config.

Why a sandbox isn't enough

The first idea is to lock the agent in a container, block file paths, restrict the network. All useful. But the agent still needs to call the API, so the key still has to exist somewhere in its environment: a variable, a file, a header. Whatever the agent can use, it can also print, forward or log.

I wanted a setup where the key never reaches the agent at all, and the agent gets something narrow and short-lived instead.

The idea: a pass instead of a key

AgentKeyGate is a small self-hosted gateway that sits between the agent and the API:

  1. The agent needs HubSpot. It sends the gateway a request: service, time limit, call limit and a reason, for example "Weekly report: deals that changed stage since Monday", 10 minutes, 20 calls.
  2. I get the request in Telegram with those facts and tap Approve, in Telegram or in the web UI. For recurring jobs a standing policy can approve automatically.
  3. The agent gets a pass like gwt_... and calls /v1/proxy/hubspot/.... The gateway adds the real key, forwards the call, counts it and filters the response.
  4. After 20 calls or 10 minutes the pass is dead. More work means a new request, and I see the reason again.

The agent never sees the HubSpot key. If someone talks it into "sending the key", there is nothing to send. A pass can leak, but it lives for minutes, has a call budget, and one click revokes it.

How it's built

  • Node.js 22, Fastify 5, SQLite via better-sqlite3. No external database; 1 vCPU and 1 GB RAM is enough for one owner.
  • Keys are stored encrypted, in AWS SSM Parameter Store or an encrypted file on the server.
  • Agents connect three ways: MCP (Streamable HTTP with the agent's bearer token), plain REST, or an Agent Skill.
  • Approval in Telegram or the web UI. Slack and email only notify.
  • Owner login with password and 2FA, an append-only audit log, and a stop button for a pass, an agent or everything.

Over MCP the agent mainly uses two tools: request_access and call. The pass stays on the server and never enters the model's context.

In Claude Code it's one command:

claude mcp add --transport http agentkeygate https://gw.example.com/mcp \ --header "Authorization: Bearer $AGENTKEYGATE_TOKEN" 

The catalog has 25 services (OpenAI, Anthropic, Gemini, GitHub, Stripe, HubSpot, Pipedrive, Zendesk, Bitrix24, amoCRM and others) plus "Other": any public HTTPS API that takes a key in a header.

Response filter: what the agent sees from your CRM

A pass limits time and calls. But 20 calls to a CRM can still pull a lot you didn't mean to share: phone numbers, names, internal notes. So for CRM and support services there is a response filter.

  • Read-only by default. For HubSpot, Pipedrive, Zendesk, Bitrix24 and amoCRM a new key only allows reads. A write gets 403 write_not_allowed and doesn't use up the call budget.
  • Data types and fields. The owner ticks data types (for example "Deals") and the fields inside them. Everything else is cut from the JSON before it reaches the agent.
  • Mask. Emails, phone numbers with + or separators, card numbers (Luhn-checked) and IBANs are replaced with ****.

Here's what that looks like for a HubSpot deals list with the default deal fields:

// HubSpot returns { "results": [ { "id": "18123", "properties": { "dealname": "Acme Corp, site redesign", "amount": "12400", "dealstage": "contractsent", "createdate": "2026-11-02T09:14:00Z", "hubspot_owner_id": "7731" } } ] } // the agent gets { "results": [ { "id": "18123", "properties": { "dealstage": "contractsent", "createdate": "2026-11-02T09:14:00Z" } } ] } 

For a "what moved this week" report that's enough. The agent doesn't need the deal name or the owner, so it doesn't get them.

Why no ML in the mask: I wanted results I can predict and check by eye. Regexes plus a Luhn check give clear rules. The cost: the mask doesn't catch names, and a bare string of digits without + or separators can't be told apart from an ID or an amount. So the field list is the main protection and the mask is a second layer.

Loosening a filter (turning off read-only, opening a field) requires a fresh 2FA code, and the UI shows a before/after diff first. A "Test with one call" button makes one real read request and shows the field names that would reach the agent, without values.

The agent knows the rules too: an approved request returns a filter object with the open types, methods, read_only and mask, so it doesn't waste calls on closed endpoints.

What it doesn't protect against

Worth saying plainly, so nobody installs it and stops thinking:

  • Anyone with root on the gateway server can read every key.
  • Inside an approved pass the agent can make any call the filter allows. Without a filter, that's any call the key allows. Give keys the narrowest scopes the service offers.
  • An agent can search on a hidden field and infer from the result count whether a value exists.
  • The mask doesn't catch names.
  • Whoever takes over your Telegram can approve requests.
  • The MCP configs for Claude Code, Cursor, Codex and other clients follow each client's docs, but I haven't checked all of them on a live client yet.
  • There has been no external security audit.

It doesn't stop prompt injection. It limits the damage: the key can't be stolen, and everything else is bounded by time, calls and the filter. The full threat model is in SECURITY.md.

Install

On a Debian 12 or Ubuntu 24.04 VPS, point an A record at the server and run:

curl -fsSL https://raw.githubusercontent.com/maximskorohod/agentkeygate/v1.0.2/scripts/setup.sh -o setup.sh echo "e0ddced59b9c360681161e300232726c32b49fb5f39ee8686765b2971faec2fc setup.sh" | sha256sum -c - sudo bash setup.sh --domain gw.example.com --vault file 

The script installs Node.js 22, Caddy with automatic TLS and systemd units, then prints a one-time setup link. There you set the owner password, turn on 2FA and connect Telegram.

It's free and Apache-2.0, with no cloud version and no paid tier: github.com/maximskorohod/agentkeygate. Site: agentkeygate.com.

If you run agents against production APIs, I'd like to hear where this model breaks for you and which services are missing from the catalog.

原文:https://www.a8q9.cn/en/article/your-ai-agent-shouldn-t-know-your-api-keys-here-s-the-gateway-i-built-instead-0cec75.html

相关文章