# 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.

Source: https://voxgig.com/howto/per-agent-api-keys-for-mcp

- Audience: platform-team
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-09-14
- Published: 2026-09-14
- Updated: 2026-09-24

## 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](https://modelcontextprotocol.io/docs/getting-started/intro) 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](https://modelcontextprotocol.io/specification/draft/basic/authorization) of the
specification covers the HTTP case in more detail.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A key per agent installation](https://nodejs.org/api/crypto.html#cryptorandombytessize-callback) | Several people or several agents, where revoking one should not affect the rest | An issuing flow, a table, and a place for a person to get their key | One internal agent that you run and nobody else uses |
| [An allowlist at the network edge](https://developers.cloudflare.com/cloudflare-one/policies/access/) | The server sits inside a network you control and identity is already solved there | Identity by address, which follows the network rather than the agent | Agents run on laptops that move between networks |
| [OAuth with per-client scopes](https://datatracker.ietf.org/doc/html/rfc6749#section-3.3) | You already run an authorization server and want consent and refresh | An authorization server, a redirect flow, and an agent that can complete it | The agent is a configuration file with a string in it |
| [One shared server key](https://www.rfc-editor.org/rfc/rfc6750.html) | A prototype on one machine, for a day | One revocation stops everybody, and no call can be attributed | More 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.

```js title="keys.mjs"
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.

```js title="keys.mjs"
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.

```bash
node demo.mjs
```

```text output
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.

```bash
node --test keys.test.mjs
```

```text output
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.

## Related how-tos

- [Rotate API keys without breaking your clients](/howto/rotate-api-keys-without-breaking-clients)

- [Keep test and live API keys from crossing environments](/howto/keep-test-and-live-keys-apart)

## Last verified

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

[^1]: The standard's word for a credential like this is bearer. [RFC
6750](https://www.rfc-editor.org/rfc/rfc6750#section-1.2) 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.

[^2]: [RFC 6749](https://www.rfc-editor.org/rfc/rfc6749#section-3.3) 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.

[^3]: GitHub redesigned its tokens on the assumption that they would be pasted somewhere. [Its
engineering post](https://github.blog/engineering/behind-githubs-new-authentication-token-formats/)
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.