AI in Production / Agents and Protocols

MCP Authorization Explained: OAuth 2.1, Resource Indicators and Token Audience

How MCP authorization uses OAuth 2.1, resource indicators and audience checks so a token for one server cannot open another, with lifetime and scope maths.

By Sandeep Belgavi · Published 2026-10-30

Three-step diagram: Server answers 401 with resource metadata. Then Client gets a token bound to that server. Then Server rejects tokens for any other audience.
Original conceptual illustration by Sandeep Belgavi. A teaching diagram, not a measurement.

An agent that talks to five MCP servers hands its access tokens to five different operators. If those tokens are valid everywhere, the least careful of the five decides your security: one debug log, one compromised dependency or one malicious server, and every other server accepts the stolen token as if it were the user. MCP's authorization specification exists largely to prevent that, by binding each token to the one server it was issued for.

This guide walks through MCP authorization as defined in specification revision 2026-07-28: the roles, the discovery and registration flow, resource indicators and audience validation, why token passthrough is forbidden, scope step-up, and the arithmetic of token lifetimes and refresh rotation. The lab simulates a day of tokens across four servers and lets you close each hole yourself.

Roles: the MCP server is a resource server, not an identity provider

The MCP authorization specification (revision 2026-07-28, the latest published revision at the time of writing) applies to HTTP-based transports. Authorization is optional, but when an HTTP server implements it, it should follow the spec; servers on stdio should not, and instead take credentials from their environment. The spec does not invent a new protocol. It selects a subset of existing standards: the OAuth 2.1 draft, Bearer token usage (RFC 6750), Authorization Server Metadata (RFC 8414), Protected Resource Metadata (RFC 9728), Resource Indicators (RFC 8707), issuer identification (RFC 9207), OAuth Client ID Metadata Documents (an IETF draft), OpenID Connect Discovery, and, now deprecated, Dynamic Client Registration (RFC 7591).

SDKs and authorization servers implement these pieces unevenly (Client ID Metadata Documents and iss validation are recent additions), so check what your SDK version and identity provider actually support before assuming a behaviour.

The roles are standard OAuth. The protected MCP server is an OAuth 2.1 resource server: it accepts access tokens and answers requests. The MCP client is an OAuth client acting for a user. The authorization server (AS) authenticates the user, records consent and issues tokens; it may be co-located with the MCP server or be an existing identity provider. The most important consequence is easy to state and often ignored: an MCP server validates tokens that someone else issued for it, and if it needs to call another API, it is a client of that API with its own credentials.

The flow, step by step

The first request from a client carries no token. The server answers 401 Unauthorized with a WWW-Authenticate: Bearer header whose resource_metadata parameter points to its Protected Resource Metadata document (RFC 9728), and ideally a scope parameter naming the scopes this operation needs. Clients must use that URL when present and otherwise probe the well-known locations: first /.well-known/oauth-protected-resource with the MCP endpoint's path appended, then the root form.

The metadata document lists the authorization servers for this resource. The client fetches the AS metadata, trying OAuth 2.0 Authorization Server Metadata and OpenID Connect Discovery locations in the order the spec gives, and must reject any document whose issuer is not identical to the issuer it used to build the URL. It must refuse to proceed if the metadata does not advertise code_challenge_methods_supported, because PKCE is mandatory and S256 must be used when technically possible.

Next the client needs a client_id. The 2026-07-28 priority order is: a pre-registered client ID if it has one; a Client ID Metadata Document, where the client_id is an HTTPS URL to a JSON document describing the client, if the AS advertises client_id_metadata_document_supported; Dynamic Client Registration as a deprecated fallback; and finally asking the user. Credentials are bound to the issuing AS: clients must key stored credentials by issuer and re-register if the AS changes.

Then the familiar authorization code flow runs, with three MCP-specific requirements. The resource parameter goes into both the authorization and token requests. The client records the expected issuer before redirecting and, on return, validates any iss parameter against it (RFC 9207) before redeeming the code, which defeats mix-up attacks across multiple authorization servers. And every subsequent MCP request carries Authorization: Bearer, never a token in the query string.

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
                         scope="catalog:read"

GET https://auth.example.com/authorize?response_type=code
    &client_id=https%3A%2F%2Fclient.example.com%2Fclient.json
    &redirect_uri=http%3A%2F%2F127.0.0.1%3A33418%2Fcallback
    &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256
    &scope=catalog%3Aread
    &resource=https%3A%2F%2Fmcp.example.com%2Fmcp
    &state=af0ifjsldkj

POST https://auth.example.com/token
grant_type=authorization_code&code=...&code_verifier=...
&resource=https%3A%2F%2Fmcp.example.com%2Fmcp

Resource indicators and token audience: two halves of one control

RFC 8707 lets a client tell the AS which resource a token is for. The MCP spec makes it mandatory for clients: the resource value must be the canonical URI of the MCP server (for example https://mcp.example.com/mcp, lowercase scheme and host, no fragment, preferably no trailing slash), and the client must send it even if the AS does not advertise support. An AS that honours it issues a token whose audience names that one server. On the other side, the server must validate that a presented token was issued specifically for it, for example by checking the aud claim of a JWT access token (RFC 9068) or the audience returned by introspection, and reject anything else with 401.

Either half alone protects nothing, which is the lesson of the lab's first mission:

Client sends resourceServer checks audienceToken replayed at another server
NoNoAccepted
NoYesUsually accepted: the AS issued a broad default audience that every server matches
YesNoAccepted: the audience is correct but nobody reads it
YesYesRejected

The blast radius arithmetic is simple. If a user's agent connects to S servers and every token is valid at every server, one token leaked from any of them opens all S. With audience binding a leaked token opens one. Agents make this matter more than usual, because a single agent host connects to many servers from different operators, and any one of them, compromised or simply careless with logs, receives tokens. In the lab, a compromised analytics server replays every token it is handed; with both halves missing it gets thousands of accepted calls in a simulated day, and with both present it gets none.

Token passthrough and the confused deputy

The spec forbids token passthrough explicitly: an MCP server must not accept or transit tokens that were not issued for it, and when it calls an upstream API it must use a separate token issued by the upstream's authorization server, not the one it received from the client. The security best-practices page lists why: the upstream's rate limits and audit controls are bypassed, logs show the wrong identity, and, crucially, for passthrough to work at all the upstream API must accept tokens issued to a different audience, which means it will also accept any stolen client token presented directly.

Mission two in the lab shows this. Audience validation is on at every MCP server, yet the upstream payments API still accepts replayed tokens, because the orders server forwards client tokens and the upstream was configured to accept them. The fix is for the orders server to obtain its own credential for the upstream: a client-credentials token if the upstream trusts the MCP server as a service, or an OAuth 2.0 Token Exchange (RFC 8693) where the upstream needs the user's identity carried through, if your AS supports it. The cost is one extra token request per cache period, which in the lab adds a handful of requests per hour.

The related confused-deputy problem appears when an MCP server acts as a proxy to a third-party API using a single static client ID at that third party. An attacker can reuse a consent cookie the third-party AS set for that shared client ID to obtain an authorization code without the user noticing. The spec's rule is that such proxy servers must obtain the user's consent for each dynamically registered client before forwarding to the third-party authorization server.

Lifetimes, refresh tokens and rotation

The spec says authorization servers should issue short-lived access tokens and, for public clients, must rotate refresh tokens. The reason is the exposure window. Suppose a token is leaked at issuance into a log that an attacker reads after a delay D, exponentially distributed with a mean of 30 minutes. The expected usable time is E[max(0, L - D)] = L - 30 × (1 - e-L/30) minutes for an access token lifetime L:

Lifetime LExpected usable time after a leakRefreshes per active hour per server
720 minabout 690 minunder 0.1
60 minabout 34 min1
15 minabout 3 min4
5 minabout 0.4 min12

Short lifetimes only stay usable with refresh tokens, otherwise every expiry sends the user back to the browser. Refresh tokens move the risk: a leaked refresh token without rotation can mint access tokens until it is revoked, which in practice means all day. With rotation, every refresh returns a new refresh token and invalidates the old one; when the legitimate client and the attacker both use the same refresh token, the AS sees reuse and can revoke the whole family, limiting the attacker to at most about one access-token lifetime. Clients that want refresh tokens may add offline_access when the AS lists it, and must not assume one will be issued; servers should not put offline_access in their challenges.

The cost of short lifetimes lands on the token endpoint: refreshes per hour scale with active users times servers times 60 / L. Mission four asks you to find that balance with a leak, a compromised server and a write-heavy week all active.

Worked example: four servers for Belgavi's Book Store

A hypothetical assistant for Belgavi's Book Store connects to four MCP servers: catalog (read-only), orders (read and write, which calls an upstream payments API), files (read and write) and a third-party analytics server. Forty staff use it for four to eight sessions a day, each with four to ten tool calls a few minutes apart. All numbers below come from the lab's synthetic model.

  1. Audience: with resource indicators and audience checks, each user holds four tokens instead of one, so first-use consent prompts rise from about one to about four per user per day, unless the AS remembers consent across resources. In exchange, a token captured by the analytics server opens nothing else.
  2. Upstream: the orders server obtains its own payments token and caches it for up to 15 minutes, adding a few token requests per hour.
  3. Scopes: catalog and analytics only ever see read scopes; orders and files step up on first write.
  4. Lifetimes: access tokens live 15 minutes with rotated refresh tokens. Token endpoint load is about 45 to 50 requests per hour for 40 users in the write-heavy scenario, leaked tokens are usable for about ten minutes on average, and unauthorized writes from the debug-log leak fall to around ten a day.

The same procedure transfers to any fleet: bind audience first, remove passthrough, then choose scopes and lifetimes by comparing the leak window with consent prompts and token endpoint load.

Detecting authorization failures in production

Authorization bugs rarely announce themselves, because a too-permissive system works perfectly for legitimate users. Instrument the paths where an attack would be visible:

  • Count tokens rejected for wrong audience per server. Zero for a month can mean nobody is attacking, or that nothing is checking; test it with a deliberately mis-audienced token in staging.
  • Alert on any upstream call made with a token whose audience is not the upstream, which is passthrough by definition.
  • Track refresh-token reuse detections at the AS. Each one is either a client bug or a stolen refresh token, and both need attention.
  • Track 403 insufficient_scope responses and step-up attempts per operation; repeated step-ups for the same operation point to a server challenging scopes one at a time or a client not sending the union.
  • Scan logs, traces and error reports for bearer tokens. The Authorization header and any field named token should be redacted at the logging library, not by convention.

The common mistakes are consistent across teams: validating signature and expiry but not audience; using one AS client and one broad audience for every MCP server because it was faster to set up; forwarding the user's token to an internal API; issuing day-long access tokens to avoid building refresh; and storing refresh tokens without rotation in desktop clients that are, by definition, public clients.

How the lab's missions map to the spec

Mission one starts with a compromised server and no audience binding; it asks for zero unauthorized calls and is solved only when the client sends resource and the servers check the audience. Mission two keeps both on but adds passthrough to an upstream API and asks you to remove the hole without disabling audience checks. Mission three leaks five percent of tokens, and half their refresh tokens, into a debug log that an attacker reads with a delay; you have to bring the stolen-token window under 20 minutes and unauthorized writes under 25 while keeping consent prompts at six per user per day or fewer, which takes a shorter lifetime, rotation and least-privilege scopes together. Mission four starts from a paranoid five-minute, no-refresh setup that prompts users constantly, and asks for a configuration that is safe, quiet and cheap at once.

Run the lab: Token audience: stop one leaked token opening every server

The lab simulates one day for 40 users of an agent connected to four MCP servers and one upstream API. The diagram shows which servers accept a stolen token: red rows let replayed calls through, green rows block them. The strip below shows user sessions across the day, with replayed stolen tokens in red. Toggle the client and server behaviours, choose a scope strategy and token lifetime, then inject a compromised server, a token leak or a write-heavy week. Each mission loads a broken configuration with a measurable target.

Prefer a full-width view? Open the standalone lab. Your stars are saved in this browser and shown on the series hub.

What to do next

  1. Confirm every MCP client you ship sends the resource parameter with the server's canonical URI in both authorization and token requests.
  2. Check that every MCP server rejects tokens whose audience is not itself, and prove it in staging with a deliberately mis-audienced token.
  3. Search your MCP servers for any code path that forwards the incoming Authorization header upstream, and replace it with the server's own credential or a token exchange.
  4. Publish Protected Resource Metadata, return scope in 401 and 403 challenges, and keep scopes_supported to the minimal read set.
  5. Set access token lifetimes from the leak-window arithmetic, issue refresh tokens with rotation, and alert on refresh-token reuse.
  6. Redact bearer and refresh tokens in logging libraries, traces and error reporting.

Sources and further reading

Related in this series:

Diagrams, examples and the lab model are original educational material. Lab numbers are calculated by a synthetic model, not measured on real hardware or services.

Views are my own and do not represent any employer, past or present.