How-to › Expose your API to agents

How to issue and rotate per-agent API keys for an MCP server#

Give every agent installation its own key carrying the tools it may call, so a revocation stops one agent rather than every agent.

Audience
Platform team
Level
intermediate
Topic
Host, transport and secure an MCP server
Languages
TypeScript and JavaScript
Verified

An engineer’s laptop is stolen and the MCP server it talked to has one key, shared by everyone. The only safe move is to rotate it, which means every other agent stops working until each person updates a configuration file. Nobody can say what that laptop’s agent had called, because every call carried the same credential.1

What you get

You will end up with one key per agent installation, each carrying the tools it may call and a record of what it did. Revoking one key stops one agent. This is for you if you host an MCP server that more than one person connects to.

Short answer

Issue one key per agent installation and store the tool list on the key rather than on the user. Show the secret once, keep a hash, and record which key made every call. Revocation and expiry then stop one installation, and a refusal can say whether the credential was unknown or the grant was missing.

You will need

Node 22 or later, and an MCP server whose tools you control. The Model Context Protocol leaves credentials to the transport, so a server reachable over HTTP needs this, and a server spoken to over standard input needs the equivalent for whoever launches it. The authorization section of the specification covers the HTTP case in more detail.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
A key per agent installationSeveral people or several agents, where revoking one should not affect the restAn issuing flow, a table, and a place for a person to get their keyOne internal agent that you run and nobody else uses
An allowlist at the network edgeThe server sits inside a network you control and identity is already solved thereIdentity by address, which follows the network rather than the agentAgents run on laptops that move between networks
OAuth with per-client scopesYou already run an authorization server and want consent and refreshAn authorization server, a redirect flow, and an agent that can complete itThe agent is a configuration file with a string in it
One shared server keyA prototype on one machine, for a dayOne revocation stops everybody, and no call can be attributedMore than one person can reach the server

Scopes are the part people skip and regret. A key that can call every tool has the whole server as its blast radius. The tools an agent needs are usually a small subset. A research assistant reads and searches, and an operations runbook is the only thing that should retire anything.

OAuth suits a team that already runs an authorization server, and it is a poor first step for one that does not. Getting per-installation keys and tool scopes in place is an afternoon, and it gives you revocation and attribution, which are the two properties an incident needs. Consent screens and refresh tokens can follow.

Put the tool list on the key

Authorization is a membership test, and it runs before the tool does.

if (row.revokedAt !== null) return deny(`key revoked: ${row.revokedReason}`)
if (clock() >= row.expiresAt) return deny('key expired')
if (!row.tools.includes(tool)) return deny(`${row.agent} may not call ${tool}`)

The three refusals are deliberately distinguishable. An agent that gets “may not call” knows to ask its owner for a grant, and an agent that gets “unknown key” knows its configuration is wrong. One generic message turns both into a support ticket.

Store a hash rather than the secret. The server never needs the original after issue, and the issuing flow is the only place a person ever sees it.

Record which key did what

Attribution is what turns an incident into a list.

row.lastSeen = clock()
const who = { keyId: row.id, agent: row.agent, owner: row.owner }
const deny = (reason) => {
  row.denied++
  return { ok: false, reason, ...who }
}

Carry the key id into your logs on every tool call. The question after a lost laptop is what that installation touched, and it is answerable in a query or not at all.

Give evaluation keys an expiry at issue. A trial that stops working on its own is one fewer thing somebody has to remember, and expiry is the only cleanup mechanism that runs without a person. Long-lived keys for production agents are a separate decision, made deliberately.

Check it worked

Three agents, three kinds of refusal.

node demo.mjs
reader           read_meter     ok as claude-desktop
reader           retire_meter   refused: claude-desktop may not call retire_meter
operator         retire_meter   ok as ops-runbook
trial            search_meters  ok as evaluation
trial expired    search_meters  refused: key expired
operator revoked read_meter     refused: key revoked: laptop lost
made-up key      read_meter     refused: unknown key

keys
  mk_x1  claude-desktop  ana  live     1 calls  1 refused  search_meters,read_meter
  mk_x4  ops-runbook     sam  revoked  1 calls  1 refused  search_meters,read_meter,retire_meter
  mk_x7  evaluation      kim  expired  1 calls  1 refused  search_meters

Lines two and three are the same tool and two different answers, decided by the key rather than by the caller’s identity or the network. Lines five to seven are three refusals a person can act on without opening a log: renew, ask the owner, or fix the configuration.

The table at the end is the artifact worth having. Three keys, three owners, three states, and two counts each. That is the answer to who has access, and it is also how you find a grant that was issued a year ago and never used.

Count the refusals beside the calls. A refusal from a known key is the most attributable event the server has. Returning it without the key id leaves an incident review looking at a row with no activity on it. The last column is how a key probing for tools it was never granted shows up at all.

node --test keys.test.mjs
1..7
# tests 7
# suites 0
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 129.622871

When it goes wrong

Every agent has every tool. The issuing flow defaults to the full list because it was easier. Make the tool list a required argument, and let a caller who wants everything say so.2

A revoked key keeps working. Something caches the authorization decision. Check on every call, and if that is too slow, cache for seconds rather than for the process lifetime. A revocation nobody can rely on is a revocation button nobody presses.

Nobody can say which agent made a call. The key id never reaches the logs. Log it on the authorization result, before the tool runs, so even refused calls are attributable. A refused call is often the more interesting of the two.

Keys are pasted into shared documents. There was nowhere else to put them.3 Show the secret once, document where it belongs in each client’s configuration, and make reissuing cheap. A key that takes a week to replace is a key people will copy rather than request.

When not to do this

Do not use per-agent keys as your only control on a destructive tool. A key says who is calling and not whether this particular retirement is a good idea. Keep a confirmation step, or an approval, on anything that cannot be undone. Scopes decide what is reachable, and a second check decides whether this particular call goes ahead.

Do not issue keys that never expire to agents that run on laptops. The device outlives the job, and a key with no expiry outlives both.

Do not reuse one key across an agent and the scripts somebody wrote around it. The moment they share a credential, revoking one breaks the other, which is the problem this page starts with.

Last verified

Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.

Footnotes

  1. The standard’s word for a credential like this is bearer. RFC 6750 defines a bearer token as one that any party in possession of can use in any way that any other party in possession of it can. No proof of holding a key is required. The definition is written for the protocol and reads as a description of the laptop. The bearer was whoever had it. ↩︎ Back to text

  2. RFC 6749 faced the same choice for OAuth scopes and declined to make it. When a client omits the scope, the authorization server must either apply a pre-defined default or fail the request, and it should document which. Both branches are compliant, and a default of everything is a pre-defined default. ↩︎ Back to text

  3. GitHub redesigned its tokens on the assumption that they would be pasted somewhere. Its engineering post explains that the old format, 40 hex characters, was indistinguishable from a SHA hash, so a scanner could not tell a leaked token from a commit id. The replacement carries a prefix, which alone was expected to bring the scanner’s false positive rate down to 0.5 percent. A 32-bit checksum in the last six characters lets a candidate be rejected without a database lookup. The underscore after the prefix was chosen partly because a double-click selects across it. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.