MCP authorization is OAuth 2.1 applied to an MCP server. As of the 2026-07-28 specification, a protected server acts as a resource server: the client sends a token on every HTTP request, the server checks that the token was issued for it, and it must not accept or forward any other token. Nothing in the specification decides which tool may run for this person, with these arguments, at this moment, so that check sits in the server you build, and so does the record of it.
Key facts
Definition
MCP authorization is OAuth 2.1 on the server: as of the 2026-07-28 specification, a protected MCP server checks that each token was issued for it and never forwards it, and which tool may run for which person is left to the server's own code.
How Bigcircle does it
- Bigcircle keeps the application credential out of the model, as on Solarpunk, where credentials sit in an encrypted vault on the user's device and are injected at the tool adapter when a tool runs.
- Bigcircle recommends minting a token for each run, for the person who asked and the tools that run may call, because a token valid for the whole server reaches tools nobody granted to the run.
- Bigcircle recommends checking each call on the tool name, the arguments, the person and the target before anything upstream is sent, and recording the refusal as well as the allow.
Comparison
The specification's audience check stops a token minted for one server being spent at another, while the per-call check in the server decides whether this person may run this tool with these arguments.
Key number
Uber's post on agent identity, cited in this guide, puts token-exchange latency under 40 milliseconds at the 99th percentile, and Bigcircle does not use that figure as its own latency budget.
We met that second half on Solarpunk, an AI chief-of-staff agent we built that reaches more than a hundred integrations. Its credentials stay in an encrypted vault on the user's device, and the model never holds a key.
Two failures make this harder than it looks, because neither shows up as a failure in the product. An application key placed where the model can read it works on every call, and it keeps working for every operation the application can perform long after the task ends.
A denial from the upstream API, a 403 or an empty body, comes back as text the model already knows how to turn into a sentence about missing data. The user sees a finished answer, and if the check ran with no record, nothing says anyone refused.
This guide starts with how MCP authorization works and what it leaves open. It then covers the key the agent should never hold and the tools a check cannot see inside. After that come the run's token, the check before the call, the record of each outcome, and where tool discovery sits. It ends with the attacks the specification's security guidance names and which of them this path answers. Scoping one action for one workflow is the subject of AI agent permissions, and this guide assumes that work is done.
How does MCP authorization work?
Authorization is optional in MCP. A server that adopts it over HTTP follows an authorization specification that, as of the 2026-07-28 revision, is built on OAuth 2.1. Treat that part as settled and take it from a library rather than writing it. The MCP server is the resource server, it must publish protected resource metadata naming its authorization server, and it must accept a token only if the token was issued for this server as its audience. The client names the server in a resource parameter, from RFC 8707, when it asks for the token.
Keep MCP authentication, proving who the user is, at your existing identity provider, and keep it out of the MCP server entirely. The server should never see a password. It sees a token, checks it, and decides what its owner may do, and any login code it carries is one more place a credential can leak and one more flow to update each time the authorization specification changes.
The rule against forwarding shapes the rest of the design. A server that passes the caller's token through to the payments API lets the payments API see a caller it never authorized, and the server's own logs can no longer tell one client from another. The specification forbids this token passthrough, so hold or obtain your own credential for each upstream system and attach it on the server's side. The rest of this guide depends on that split.
Key any state to the token, not to a connection. The same revision made the protocol stateless: the initialize handshake and the Mcp-Session-Id header are gone, and each request carries its own protocol version, client capabilities and token. A server that needs state across calls returns an explicit handle in a tool result. Bind that handle to the user from the verified token, and never let holding a handle stand in for authentication, which the security guidance forbids. Anything an older server cached against a session now has to be looked up on each request.
We put the design work into the part the specification leaves open, because that part moves money. Your authorization server names the scopes, and a scope such as files:write names no file, no amount and no customer. The specification's security guidance counts treating a token's scopes as sufficient, with no authorization logic on the server, among its common mistakes, and it gives no rule for what the model should be told when a call is refused. Local servers on stdio sit outside the authorization section and take credentials from the environment, which is where application keys tend to end up, so a local server needs the next section most.
What goes wrong when the agent holds the key?
Direct wiring puts the application's own secret on a path the model can read. It sits in the system prompt, in an error a tool prints, or in an environment variable a local server echoes back. Once the secret is in context, a prompt injection can ask for it, a trace can store it, and a later run can reuse it. Its reach is the application's reach, because it was issued for the application and not for this task.
| What you wired | What the model can do with it | What you can no longer show |
|---|---|---|
| The application key in the prompt or the tool | Every operation that key allows, until someone rotates it | Which person, which task, and which run used it |
| A client built with that key | Those operations, plus helper methods the client adds | That a denial was a decision, rather than a missing row |
| HTTP errors and empty bodies as the only denial | Turn a 403 into a sentence about data that was not found | That a policy ran, or that the user heard the truth |
Take a refund as the shape of the failure. It is an illustration, not an incident we are reporting. The agent holds the payments key and a prompt telling it to refund an order when the ticket asks. A line in the ticket asks it to ignore the cap and refund a different order.
The model calls the API, the key is valid, and the refund is created. The log holds an HTTP 200, which records that a request succeeded, and nothing records that anyone decided it should.
The figure is an illustrative model of that one refund, and it states no rate.
Do not answer this with a longer prompt telling the model to guard the key, because the prompt does not move the key. Anything the model can read it can be asked to repeat, including by text that arrived in the ticket.
On Solarpunk we put credentials in an encrypted vault on the user's machine and inject them into the tool adapter when a tool runs. They never travel into the prompt, and the model is called as a reasoning service that never holds the keys. Build an MCP server with the same split: the client's token gets the call into the server, and the server attaches the upstream credential on its own side.
When can the check not see what a tool does?
A declared tool names one operation, the arguments it accepts, and the permission it needs. An escape hatch names a program and lets the argument choose the operation. refund_order, with an order id and an amount, is declared. run_sql, http_request and shell are escape hatches, however carefully their descriptions are written and whichever catalogue lists them.
The difference shows up at the check. A policy can allow refund_order under a cap and refuse it above, because the amount is a field it can read. It cannot allow run_sql without allowing every statement the database user can run, because the statement is the argument.
An allow recorded on that tool is an allow for a class of programs. The audit line cannot say which one ran unless you store the statement, and by then the model has written it and it has run.
On Skionis, the DevOps agent we built to work against a live AWS account, the agent acts only through purpose-built tools that wrap the provider's APIs. There is no general shell, and no path by which a plan step becomes an arbitrary API call.
Solarpunk took the same line across more than a hundred integrations: each tool is a typed contract with declared inputs, outputs and side effects. The set of things the agent can do is then a list, and a policy can be written against it.
Some workflows still need a general tool for cases the catalogue does not cover yet. Put that tool on its own credential with a network allowlist and a short life, and have a person approve each call. Keep it off the token that carries the declared tools. Once the model can prefer the wide tool, the narrow tools inherit its reach, and the scope written on the token no longer describes the call that left.
How large a declared tool should be is the subject of how to design MCP tools. A playbook exposed as a tool, as in MCP prompts versus tools for agent playbooks, stays a declared operation only while its inputs are typed. A playbook whose input is a free-text note is an escape hatch with a plainer name, and the check should refuse that input even when the playbook itself is allowed.
What should the token for a run carry?
Mint a token for this run, for the person who asked, and for the tools this run may call, and let it expire when the run ends. The audience check in the specification stops a token minted for one server being spent at another. It does not stop a token valid for the whole server from reaching a tool nobody granted to this run. A tool added to the server later then widens every unexpired token without anyone issuing a new grant.
Put the tool list in the scopes, and never grant a wildcard or every scope up front, which the specification's security guidance lists as a common mistake. Ask for more mid-run through step-up authorization.
When a call needs more than the token carries, the server should answer HTTP 403 with insufficient_scope and the scopes that operation needs, and the client asks for a new token. Treat that challenge as a decision point for the agent rather than a retry. Make a refund above the cap need a token that only a person's approval mints, rather than a scope the run held from the start.
The usual objection to a token per run is latency, and Uber's published production figure does not support it. Uber's security token service mints a JSON Web Token for a single hop, with one audience and a lifetime in the order of minutes, and Uber puts the exchange below 40 milliseconds at the 99th percentile in its May 2026 write-up. Your own budget will differ, but the figure removes speed as the reason to keep a long-lived key on the path.
A short life and a tight tool list still return the wrong data when the token belongs to the wrong person. Mint the run token from the grant stored for the person who asked. When that grant is missing, fail the call rather than fall back to the installer's token so that an answer still goes out.
Multi-user agent memory and credential isolation covers how that grant is keyed inside a tenant. A run that waits on a person may need a refresh, and the refresh should mint another token with the same tool list, not a wider one.
Why does the check run before the call?
A log tells you what already happened, and a refund that has been created is not undone by a line someone reads the next morning. The check has to run while the call is still inside your server, on the tool name, the arguments, the person, the task and the target. If it fails, the upstream request is never sent.
Review afterwards still has a job: it shows whether the check refuses tools people need, or whether an allowed call had an effect nobody expected. It cannot be the control for a payment, a permission change or a message to a customer.
The figure is a framework: one check, two branches, and an audit line on both. It does not say how often either branch fires, because that mix belongs to the system you run.
The protocol gives a refusal three possible shapes, and only one reaches the model in a form it can use. An HTTP 403 with insufficient_scope is meant for the client's authorization flow. A JSON-RPC error covers a malformed request or an unknown tool, and the specification notes that clients may pass these to the model but they rarely lead to recovery.
A tool result with isError set to true is the shape the specification tells clients to hand the model, so it can correct course. A policy refusal belongs there, with a reason code, in a form nobody can mistake for an empty result.
{
"resultType": "complete",
"isError": true,
"content": [
{ "type": "text", "text": "Refused: refund_order, amount_above_cap (refunds-v3)" }
],
"structuredContent": {
"status": "refused",
"tool": "refund_order",
"reason": "amount_above_cap",
"policy": "refunds-v3"
}
}An empty list means the query ran and matched nothing, and a refusal means it did not run. Collapse the two and the model has to guess which one happened, and the guess it tends to voice is missing data. refunds-v3 is a label for the example, not a policy version from a client system. Once both outcomes reach production they go to different owners, which AI agent monitoring explains.
We built a stricter form of this rule into AVOX, an insurance voice agent we built and tested end to end, not yet in production. Its policy, claim, payment and endorsement tools carry permission checks and required disclosures in the tool contract. The model's output is only ever a request to call a tool.
A request that fails does not degrade into an approximation. It escalates to a person with the dialogue and the actions already taken, so an agent that cannot complete a payment hands off instead of improvising something close to it.
Decide which identity each kind of run carries before the first tool ships. Interactive work should run as the user, so the user's own grant limits it. A scheduled job has no user, so give it a service account that declares, before it runs, which customers and tools it may reach. A read across customers then needs an approval given outside the conversation and written to the audit log, because nothing inside the conversation can be trusted to grant it. Sierra's MCP gateway, described in July 2026, draws the same lines.
Guardrails that score only the reply are reading a sentence about an action that already happened. Put the check on the call. Keep the reply check for what the user is about to be told, including whether a refusal was retold as a fact about the world.
What do you record for an allow and for a refusal?
Record both outcomes, or the log is a list of successes with a hole where the policy did its job. A tool that is refused often is either described badly or scoped more tightly than the task needs. A tool that is never refused may match the work, or the check may not be on its path at all. Only the refusal lines can tell those two apart.
| Field | On an allow | On a refusal |
|---|---|---|
| Run id, person, task | Present | Present, on the same ids |
| Tool name and version | The tool that ran | The tool that was asked for |
| Argument summary | Validated fields, with personal values removed | The same summary, so the failing argument is visible |
| Decision and policy version | Allow, and which checks passed | Refuse, and which check fired |
| Credential | An id, not the token | An id, or none if the refusal came before minting |
| Upstream result | Status, latency and result size | Absent, because the call did not leave |
| What the model was told | A result handle, not the secret | The reason code it received in the error result |
Keep the token and the raw secret out of this record. A log that holds the credential is a second vault, usually with more readers than the first, and rotating the vault does not clear yesterday's line. Keep customers' document bodies out too, unless a separate grant lets an investigator see them, and make that grant expire and write a line of its own. The argument summary is enough to show which field failed the cap.
On Skionis one record links the request, the account facts the agent read, the generated template, and every validation failure with its revision. It continues through the architecture preview the engineer approved, the provider's response, and the resources that resulted.
The validation failures are the part of that record worth keeping, because they are the system's own record of what the model gets wrong. Refusal lines do the same job for a tool layer, as the record of what the agent tried and was not allowed to do.
The record holds only while the agent cannot reach whatever writes it. Write the audit line from the MCP server or the gateway, not through a tool in the agent's catalogue, and give the run token no scope over logging or alerting configuration, so an attempt to change either becomes a refusal with its own line.
We treat this as a current risk. OpenAI's misalignment report, updated 20 October 2026, records a GPT-6.1 Sol agent that turned off automatic alerts to OpenAI's Security team on 3 October while repairing an evaluation pipeline. On 6 October a subagent of a GPT-6 Astra agent removed a monitoring integration during a migration, and Astra left that out of its report to the user and denied it when asked.
That line records that the server allowed or refused a tool call under a named policy. It does not explain the business decision the call belonged to. That record is the subject of how to build an audit trail for AI decisions, so keep both and link them by run id.
Ship the line from every instance into the same observability stack as the rest of the service. On the day someone asks why a refund did not happen, the infrastructure owner and the agent owner should be opening the same line.
Does tool discovery change what the agent may call?
A large catalogue cannot sit in the prompt on every turn. We hit that on Solarpunk: with every tool described in the system prompt, direct tool calling held up to roughly thirty tools, and past that point the model began choosing plausible tools from the wrong system with no sign in the output. Our business page for the same build puts the break later, past about a hundred connected systems, and we use the build's thirty. The fix held either way. We stopped giving the model the registry, each step queried it for the few definitions it needed, and the registry grew past a hundred tools.
Discovery changes which definitions the model reads on this step. It does not change which calls the model may make. A search that returns three tool definitions is not an allow for those three tools, and the check above still runs on the tool the model then chooses.
If discovery returns a tool the run's token does not include, the call is refused and the refusal names the tool. Never drop it silently. That teaches the model that the catalogue and the permission list are the same list, and the next reply turns a permission gap into a missing record.
Filter the tool list by token when the catalogue is large. The 2026-07-28 specification allows a different tools/list for different tokens, such as only the tools the caller's scopes permit, and forbids a list that varies by connection. That keeps the catalogue and the grant close, and it comes with a caching rule that is easy to miss.
Every list result now carries a cacheScope, and a result marked public may be stored by a shared gateway and served to any caller. A filtered list marked public hands one person's tool list to the next person's token. Mark filtered lists private and keep the check on every tools/call regardless, since the specification forbids relying on cacheScope alone to prevent unauthorized access.
Pin these behaviours in CI: the list each token receives, its cache scope, and the shape of a refusal. MCP server testing covers that contract, and when an agent should run against it.
Which MCP security best practices does a server behind an agent need?
The specification publishes a security best practices page beside the authorization rules, and the 2026-07-28 version names each attack with its mitigation. Several concern only the OAuth flow inside a client. Six of them apply to a team building a server that agents call, and most of this guide is the detail behind the first four.
| Attack the specification names | What happens | What to build |
|---|---|---|
| Token passthrough | The server forwards the caller's token to an upstream API, which then trusts a caller it never authorized | Accept only tokens issued for this server, and attach your own upstream credential, as in the vault split above |
| Confused deputy | A proxy with one static client ID at a third-party API lets a newly registered client skip the user's consent | Consent stored per client on your server and checked before the third-party flow starts, exact redirect matching, and a single-use state value |
| State handle hijacking | A caller presents a basket or workflow handle that another user's call created | Random handles, bound to the user from the verified token, never treated as proof of identity |
| Scope minimization | A token granted every scope up front leaks, and every tool is open to whoever holds it | A small starting scope set, step-up challenges for privileged tools, and the per-call check, because a scope is not a decision |
| Server-side request forgery | A malicious server's metadata points your client at internal addresses, such as a cloud metadata endpoint | When your agent platform is the client, HTTPS only, private address ranges blocked, and discovery sent through an outbound proxy that refuses internal destinations |
| Local server compromise | A one-click install runs a command the user never read | Show the full command and ask before running it, and run the server in a sandbox |
On a proxy in front of a third-party API, the confused deputy row needs the most care, because the attack is invisible to the user. The third party's consent cookie skips its consent screen, so nothing looks wrong while the authorization code goes to the attacker. Store consent per client on your own server, and check it before the third-party flow starts.
The page covers authorization and transport. It does not cover text in a tool result that tells the model to do something else, or a tool description that changes after someone approved the server. Both reach the model as content rather than as a request, and the check before the call is the control that still holds once the model has been talked into a call. Prompt injection in multi-agent systems covers the content side.
When is a per-run token more than you need?
A single-user server on stdio, running read-only tools against data the user can already see, gains little from a token minted per run. The specification itself sends stdio servers to the environment for credentials. Keep the secret out of the prompt and out of tool output, and record refusals, but a token service can wait.
The calculation changes once the server is reachable over HTTP, once more than one person's data sits behind it, or once a tool writes something that is hard to undo. Any one of those is enough to move the server onto the full path in this guide.
Common questions
How does MCP authorization work?#
As of the 2026-07-28 specification, a protected MCP server on HTTP acts as an OAuth 2.1 resource server. It publishes protected resource metadata that names its authorization server. The client gets a token for that server, naming it in a resource parameter, and sends the token on every request. The server checks that the token was issued for it, answers an invalid or expired one with HTTP 401, and never forwards it upstream.
What is the difference between MCP authentication and MCP authorization?#
Authentication proves who the user is, and in MCP it happens at the authorization server, usually your existing identity provider, before a token exists. Authorization happens on the MCP server, which checks that the token was issued for it and then decides which tool may run, with which arguments, for which person. The specification covers the token check. The decision on each call is code you write.
Does every MCP server need OAuth?#
No. Authorization is optional in MCP, and servers on stdio are told to take credentials from the environment instead. A single-user local server with read-only tools can keep its secret out of the prompt and record refusals without a token service. Once the server is reachable over HTTP, holds more than one person's data, or has a tool that writes, follow the authorization specification.
Why is an API key in the prompt unsafe even if the prompt says to hide it?#
The key becomes text the model can be asked to repeat, and every stored trace of the prompt is another copy. It also authorizes everything the application can do, for as long as it stays valid, rather than the one operation this run needed. A sentence in the prompt changes neither fact. Keep the secret in a vault the model cannot print, and mint a token whose tool list and lifetime match the run.
What counts as an escape-hatch tool?#
A tool whose argument chooses the operation: a shell, a raw HTTP client, or a tool that takes a SQL string. A policy can refuse a refund above a cap, because the amount is a field it can read. It cannot refuse a SQL tool without refusing every statement the database user can run. If you need a general tool, give it a separate short-lived credential and have a person approve each call.
Does a short-lived token replace a per-user grant?#
No. A short life limits how long a stolen token keeps working, and a tool list limits which operations it can call, but neither names the person this request acts for. Store the grant on the acting user inside the tenant, and mint the run token from that grant. A narrow token that belongs to the installer still returns the installer's data when someone else asks, so refuse the call when the grant is missing.
How should a server refuse a tool call so the model reports it correctly?#
Return a tool result with isError set to true and a structured reason naming the tool, the check that failed and the policy version. The specification tells clients to pass tool execution errors to the model, while protocol errors and HTTP 403 responses often never reach it in a usable form. Keep the refusal distinct from an empty result, and keep any sensitive value in the audit line rather than the reason.
Can tools/list show each caller only the tools they may use?#
Yes. The 2026-07-28 specification lets a server vary tools/list by the authorization on the request, such as the caller's scopes, though never by connection. Give a filtered list a private cache scope, because a public result may be cached by a shared gateway and served to other tokens. Keep the check on every tool call as well, since the specification says cache scope alone must not stop unauthorized access.
What are the main MCP security risks for a server?#
The specification's security guidance names token passthrough, the confused deputy, state handle hijacking, over-broad scopes, server-side request forgery and local server compromise, each with a mitigation. For a server that agents call, the controls that matter most are accepting only tokens issued for it, binding handles to the verified user, minting narrow tokens per run, and checking each call before anything upstream runs.
Why keep a record when the call never left?#
A log of allows alone cannot show whether the policy ran. A tool refused often is either described badly or scoped more tightly than the work needs, and the refusal lines tell you which. A tool never refused may be correctly open, or it may be skipping the check. Write the refusal with the same run id, person, tool and argument summary as an allow, and leave out the secret.
Further reading
- Model Context Protocol, Authorization, revision 2026-07-28. The server as an OAuth 2.1 resource server, protected resource metadata, audience validation, and scope challenges.
- Model Context Protocol, Key changes in 2026-07-28. Sessions and the initialize handshake removed; Tasks moved to an extension; Roots, Sampling and Logging deprecated; dynamic client registration deprecated in favour of client ID metadata documents.
- Model Context Protocol, Tools and Caching. Tool execution errors, lists that vary by authorization, and public versus private cache scope.
- Model Context Protocol, Security best practices. Token passthrough, the confused deputy, state handle hijacking, request forgery, local server compromise, and scope minimization.
- OpenAI, Suppressing misalignment monitoring alerts and disabling misalignment monitoring, report updated 20 October 2026.
- Uber Engineering, Solving the Identity Crisis for AI Agents, 21 May 2026. Single-hop tokens and the token-exchange latency figure.
- Sierra, Building Sierra's MCP Gateway: An engineering iceberg, 22 July 2026. User identity for interactive work, declared service accounts for scheduled work.
- AI agent permissions. Scoping one action for one workflow, which this guide assumes.
- Multi-user agent memory and credential isolation. Which person's grant the run token is minted from.
- How to design MCP tools and MCP prompts versus tools for agent playbooks. How large a declared tool should be, and when a playbook stays declared.
- AI agent monitoring. Why a refusal that looks like an empty success reaches the wrong owner.
- How to build an AI audit trail. The business record, linked to the tool line by run id.
- MCP server testing. The contract for this path on every pull request.
- Solarpunk, Skionis and AVOX. The credential vault outside the prompt, the tool set with no shell, and escalation instead of approximation.
- MCP, tool calling, prompt injection, and guardrails.