Tutorial

How to add OAuth 2.1 to an MCP server: Authorization Server to Access Token for the remote MCP API

Stateless MCP fixes sessions, not who may call your tools. Once a remote MCP hangs on the public internet, auth must run from Authorization Server discovery to an Access Token—not a shared secret stuffed into JSON-RPC.

MCP 2026-07-28 marks HTTP authorization as optional; once you protect a resource, you must implement the OAuth 2.1 subset. Cursor, Claude Desktop, OpenAI Responses remote MCP, and home-grown agent hosts all start with an unauthenticated request. The server returns 401 and points at Protected Resource Metadata in WWW-Authenticate. The client then discovers the Authorization Server, registers, runs authorization code + PKCE with a resource parameter, receives an Access Token, and puts Authorization: Bearer on every HTTP hop. This is that remote MCP API path in practice: who does what, what the metadata looks like, how to bind audience, how to step up scope. Spec: MCP Authorization. We already covered what MCP is, Stateless MCP, and inputSchema and tool calling—this article only adds “who is allowed to call.” Do not run this browser dance on STDIO; take credentials from the environment.

Three roles: the MCP server does not issue tokens

The first mistake is treating the MCP server as the token issuer. In the spec, a protected MCP server is an OAuth 2.1 resource server: it accepts, validates, and consumes Access Tokens bound to its audience. The MCP client is an OAuth 2.1 client: it obtains a token for the resource owner and calls tools with it. The Authorization Server (AS) handles login, consent, and issuance. The AS may sit with the resource server or be an existing IdP (Okta, Keycloak, Auth0, homemade OIDC). The spec does not tell you how to build an AS; it tells the MCP server how to advertise where it is—see Authorization Server Discovery.

Authorization is optional for MCP. HTTP transports that protect resources SHOULD follow this spec. STDIO MUST NOT. Stashing a shared API key in tools/call params, or a token in JSON-RPC _meta, is not 2026-07-28 remote MCP auth. Auth lives on the HTTP transport, not inside the method envelope.

Against our other layers: MCP is discovery and calling; statelessness removed the session, not auth; inputSchema shapes arguments, and a passing Schema does not mean the caller is allowed. OAuth answers “was this Bearer issued for this resource, and is the scope enough?” The agent loop itself: what an AI agent is.

Role OAuth 2.1 hat What you ship
MCP client / hostOAuth clientDiscovery, PKCE, store tokens, Bearer on every hop
MCP serverResource server401 + PRM, validate tokens, bind audience
Authorization ServerIssuer / IdPLogin, consent, auth code, Access Token

Find the Authorization Server: 401 and RFC 9728

The lab starts with a failed call. The client hits https://mcp.example.com/mcp with tools/list or any JSON-RPC and no Authorization. The server MUST return HTTP 401 with Bearer and resource_metadata on WWW-Authenticate. Include scope so the client knows the minimum to request. Clients MUST parse that header: use resource_metadata when present; otherwise probe well-known URIs per RFC 9728.

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

MCP servers MUST implement OAuth 2.0 Protected Resource Metadata (RFC 9728). The document MUST list at least one AS issuer in authorization_servers. You may also set resource (this server’s canonical URI), scopes_supported, and bearer_methods_supported. The client then fetches AS metadata: for a pathless issuer, try /.well-known/oauth-authorization-server then OpenID openid-configuration; with a tenant path, insert the path in the spec’s priority order. The issuer in the document MUST be a byte-for-byte match of the issuer used to build the URL. If not, treat it as an attack and drop the document.

{
  "resource": "https://mcp.example.com/mcp",
  "authorization_servers": [
    "https://auth.example.com"
  ],
  "bearer_methods_supported": ["header"],
  "scopes_supported": [
    "invoices:read",
    "invoices:write"
  ],
  "resource_documentation": "https://mcp.example.com/docs"
}

Above: the 401 and a minimal Protected Resource Metadata document. resource must not carry a fragment or omit the scheme. Be consistent about the trailing slash; the spec prefers none unless it is meaningful. If you list several authorization servers, each is an independent issuer—clients MUST keep registration state separate.

Register the client: CIMD, pre-registration, DCR

After the AS is found, the client MUST have a client_id before it can authorize. Three mechanisms, in spec priority: ① Client ID Metadata Documents (CIMD)—the client_id is an HTTPS URL; the AS fetches that JSON and checks redirect_uris; ② pre-registration from a console (confidential or public); ③ Dynamic Client Registration (RFC 7591) POST /register. DCR is deprecated and kept only for AS software that cannot do CIMD. New work should prefer CIMD or pre-registration.

When authorization_servers lists more than one, each is a separate AS. Store client_id, secrets, and tokens per AS. Never send AS A’s credentials to AS B’s token endpoint. That is a common mix-up / confused-deputy door.

Public clients (desktop hosts, browser extensions) MUST use PKCE under OAuth 2.1. Confidential clients should too. Do not invent “put client_secret in the MCP server env and proxy registration to the AS.” The MCP server is a resource server, not a client.

Auth code + PKCE + resource: get an Access Token

Only then comes the authorization-code flow. Before opening the browser the client MUST: generate a PKCE code_verifier / code_challenge; put resource (the MCP server’s canonical URI, RFC 8707) on both the authorization and token requests; pick scopes (prefer the 401 scope, else scopes_supported); record the validated AS issuer on the same per-request record as the verifier. After consent, compare callback iss to that recorded value with a simple string compare (RFC 9207)—no case folding, no slash stripping.

The token request carries code, code_verifier, and resource. Success is JSON: access_token, token_type Bearer, expires_in, maybe refresh_token and scope. Do not assume a refresh token. Do not put offline_access in the MCP server’s scopes_supported or 401 scope—that is a client-to-AS ask, not a resource requirement. Key AS metadata fields follow; if authorization_response_iss_parameter_supported is true, a callback without iss MUST be rejected.

{
  "issuer": "https://auth.example.com",
  "authorization_endpoint": "https://auth.example.com/oauth/authorize",
  "token_endpoint": "https://auth.example.com/oauth/token",
  "jwks_uri": "https://auth.example.com/.well-known/jwks.json",
  "registration_endpoint": "https://auth.example.com/oauth/register",
  "code_challenge_methods_supported": ["S256"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "response_types_supported": ["code"],
  "token_endpoint_auth_methods_supported": [
    "none",
    "client_secret_basic"
  ],
  "authorization_response_iss_parameter_supported": true,
  "scopes_supported": [
    "invoices:read",
    "invoices:write",
    "offline_access"
  ]
}

To the client an Access Token is often opaque; when you debug it is often a JWT. Read iss, aud (or resource), scope, exp, client_id. aud MUST bind to this MCP server. A token minted for the payments API must not run tools/call. The payload below has the right aud; point it at another API’s URI and the resource server MUST 401.

{
  "iss": "https://auth.example.com",
  "sub": "user_1842",
  "aud": "https://mcp.example.com/mcp",
  "client_id": "https://host.example.com/oauth/client.json",
  "scope": "invoices:read",
  "exp": 1790000000,
  "iat": 1789996400
}

Call the remote MCP API with Bearer and check audience

After the token, every Client → MCP server HTTP hop MUST carry Authorization: Bearer: discovery, tools/list, tools/call, resources/read. Tokens MUST NOT appear in the query. JSON-RPC method names and arguments stay in the body—the auth header and the envelope are two layers. Stateless MCP has no session that means “logged in, skip the check”; each request authenticates itself. See Stateless MCP.

POST /mcp HTTP/1.1
Host: mcp.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
Accept: application/json, text/event-stream

{
  "jsonrpc": "2.0",
  "id": 42,
  "method": "tools/call",
  "params": {
    "name": "searchInvoices",
    "arguments": {
      "startDate": "2026-01-01",
      "endDate": "2026-01-31",
      "status": "paid"
    }
  }
}

The server validates per OAuth 2.1 §5.2: signature or introspection, exp, iss, and RFC 8707 audience. Failures are 401. After audience, still confirm this AS issued the token. Servers MUST NOT accept or transit tokens meant for someone else. Clients MUST NOT send another resource’s token to this MCP server. That is the confused-deputy rule.

A valid token does not make arguments valid. Bearer answers “who, which resource, which scopes.” Dates and enums on searchInvoices still need parse + Schema + business rules. Do not make “token ok” the only gate on tools/call. Shape contract: MCP and JSON Schema.

Scopes, 403, and debugging in JSONVue

When the token is good but the scope is not, the server SHOULD return 403 with error="insufficient_scope", the scope needed for this operation, and the same resource_metadata on WWW-Authenticate. Put every scope this operation needs in one challenge—do not drip one missing scope per retry. On step-up the client unions old scopes with the challenge so other tools do not lose grants.

HTTP Meaning What you do next
401Unauthenticated, or token invalid / expired / wrong audienceRead PRM; re-authorize or refresh
403 + insufficient_scopeToken valid, permission shortStep-up: merge scopes and authorize again
400Malformed authorization requestFix the client request; do not retry the tool
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
  scope="invoices:write",
  resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
  error_description="Write scope required to create a credit note"

A lab session produces at least four JSON documents: Protected Resource Metadata, AS metadata, the token response, and (if JWT) the payload—plus tools/call params.arguments. They have different jobs; do not mash them into one “universal” Schema. Keep one failing fixture each: PRM missing authorization_servers, AS metadata with a mismatched issuer, a JWT with the wrong aud, arguments missing a field.

You can do this in the browser: JSON formatter to see if metadata parses; JWT decoder for aud / scope / exp; JSON Schema validator against the contract you wrote for PRM; JSON Diff to compare payloads before and after refresh, or model arguments vs MCP params. Nothing leaves the machine.

Further reading: What is MCP, Stateless MCP, MCP and JSON Schema, A2A vs MCP, What is an AI agent.

FAQ

Does a local STDIO MCP need OAuth 2.1?

No. The spec says STDIO should not run this browser flow; take credentials from the environment. OAuth 2.1 is for HTTP remote MCP. The same tools can expose STDIO and Streamable HTTP; only the HTTP face needs 401, PRM, and Bearer.

Can I put the Access Token in JSON-RPC params or _meta?

Not as a conforming implementation. The Access Token MUST ride in the HTTP Authorization header and MUST NOT appear in the query. Envelope _meta is for protocol versioning, not login. A token in the body will show up in logs, proxies, and model context.

If I mint JWTs myself and the server checks a shared secret, have I implemented MCP OAuth?

Signature checks are not enough. The client discovers an Authorization Server, not “a key baked into the server.” You still need RFC 9728 metadata, authorization_servers, resource on authorize and token requests, audience binding, and 401 / 403 challenges. A homemade AS may issue JWTs; discovery and the resource parameter are not optional.

Our API gateway already verifies JWTs. Do we still need this?

The gateway can check signatures and expiry. MCP hosts still need to discover the AS, send resource, and parse WWW-Authenticate. Verifying “any valid JWT” without audience binding lets tokens minted for other APIs in. Treat MCP as plain REST and hosts often cannot complete the handshake.

Summary and next steps

OAuth 2.1 on remote MCP is not “add a login page.” It is a transport contract: 401 discovery, AS metadata, client registration, PKCE, resource, Access Token, Bearer on every hop. The MCP server is the resource server. The Authorization Server issues tokens.

Ship in this order: make 401 + Protected Resource Metadata parseable by an existing MCP client, then attach the AS, then write tools/call business logic. Keep metadata and JWT fixtures in JSONVue, separate from Schema validation. Protocol entry: what MCP is. Stateless transport: Stateless MCP.