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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A key per agent installation | 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 | 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 | 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 | 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.
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.
Related how-tos
Last verified
Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.
Footnotes
-
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
-
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
-
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