Authorization

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against the 2026-07-28 revision of the specification (which cites the OAuth 2.1 draft draft-ietf-oauth-v2-1-13), Spring Boot 4.1.1 with Spring Security 7.1.1, the Python mcp SDK 2.2.0 with PyJWT 2.15, @modelcontextprotocol/server 2.2.0 with @modelcontextprotocol/express 2.0.1 and jose 6.2, as checked on 2026-09-29 (see the Model Context Protocol baseline). Python examples target Python 3.12, Java examples Java 21 and TypeScript examples Node.js 24. The MCP Java SDK and Spring AI speak protocol 2025-11-25; authorization is an HTTP-level concern and works the same for them, so the Spring Security code below protects a Spring AI 2.0.1 server unchanged.

An MCP server on the internet exposes tools that read data and change things on behalf of a user. It must know who is calling and what they are allowed to do, and it must be sure the credential presented was meant for it. MCP does not invent an authentication scheme for this. It profiles OAuth 2.1 and a handful of existing RFCs so that a client that has never heard of a server can discover how to get a token for it, on its own. This page documents only that profile. The OAuth fundamentals — grants, PKCE, tokens, scopes, sender-constraining — are on the site’s OAuth pages and are linked rather than repeated.

Scope and roles

Authorization is optional in MCP and applies to the HTTP transports. An implementation on stdio should not follow this specification and should read credentials from the environment instead (see Transports). An MCP server that requires a token acts as an OAuth 2.1 resource server: it accepts and validates access tokens on its endpoint. The MCP client is the OAuth client, acting for the user (the resource owner). The authorization server (AS) authenticates the user and issues the tokens. The specification deliberately leaves the AS implementation out of scope; it may be a separate product or part of the same application.

Table 1. Read these first for OAuth fundamentals
Topic Site page

Choosing a flow

Choosing an OAuth Flow

Authorization code with PKCE (what MCP clients run)

Authorization Code and PKCE

Metadata documents and client registration (RFC 8414, RFC 7591)

Discovery, Metadata and Client Registration

Access and refresh tokens, expiry and rotation

Access and Refresh Tokens

JWT validation (signature, issuer, audience, expiry)

JWT and the JOSE Family

Opaque tokens and introspection

Opaque Tokens, Introspection and Revocation

Scopes and permissions

Scopes, Claims and Permissions

Machine-to-machine access

Client Credentials and Client Authentication

Delegation across services (the alternative to token passthrough)

Token Exchange and Assertion Grants

Sender-constrained tokens

Sender-Constrained Tokens: DPoP and mTLS

General hardening

Security Best Practices

Spring Security resource servers and authorization servers

Spring Boot Integration: Overview

What is MCP-specific

The generic OAuth machinery is the same. What the MCP profile adds, and what an MCP client and server must do, follows the order of the flow.

sequenceDiagram participant U as User participant C as MCP client participant M as MCP server (resource server) participant A as Authorization server C->>M: POST /mcp without a token M-->>C: 401 WWW-Authenticate Bearer resource_metadata and scope C->>M: GET Protected Resource Metadata M-->>C: resource and authorization_servers C->>A: GET authorization server metadata A-->>C: endpoints, code_challenge_methods_supported, iss support Note over C,A: Client identity via pre-registration, CIMD or DCR C->>U: open browser to authorization endpoint Note over C,A: PKCE S256, resource parameter, requested scope U->>A: sign in and consent A-->>C: redirect with code and iss Note over C: Compare iss to the recorded issuer before using the code C->>A: token request with code_verifier and resource A-->>C: access token for the MCP server audience C->>M: POST /mcp with Authorization Bearer token M->>M: check signature, issuer, audience, expiry, scope M-->>C: tool result

Discovery: Protected Resource Metadata and the 401 challenge

A client starts by calling the MCP endpoint without a token. A server that requires authorization answers 401 with a WWW-Authenticate: Bearer challenge (RFC 6750). MCP servers must implement OAuth 2.0 Protected Resource Metadata (RFC 9728), a small JSON document that names the resource and the authorization servers that issue tokens for it, and they must make it discoverable in at least one of two ways: by putting its URL in the resource_metadata parameter of the challenge, or by serving it at a well-known URI. A client must support both, using the header value when present and otherwise trying, in order, the path-suffixed URI and the root one:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp",
                         scope="docs:read"
{
  "resource": "https://mcp.example.com/mcp",
  "authorization_servers": ["https://auth.example.com/realms/docs"],
  "scopes_supported": ["docs:read"],
  "bearer_methods_supported": ["header"]
}

authorization_servers must contain at least one entry, and the client chooses among several. Each listed server is an independent OAuth server: the client must keep separate registrations and tokens per authorization server and must not assume credentials for one work at another. The scope in the challenge tells the client the scopes this operation needs; if it is absent, the client falls back to scopes_supported. Servers should ask for the least privilege that works and should not put offline_access in either place, since refresh tokens are not a resource requirement.

Authorization server metadata (RFC 8414)

With the authorization server’s issuer in hand, the client fetches its metadata. MCP uses the default oauth-authorization-server well-known suffix of RFC 8414 and requires the client to also try OpenID Connect Discovery, because deployed servers publish one or the other. For an issuer with a path (https://auth.example.com/tenant1) the order is the RFC 8414 document with the path inserted, then the OIDC document with the path inserted, then the OIDC document with the path appended; for an issuer without a path it is /.well-known/oauth-authorization-server then /.well-known/openid-configuration. The client must validate the document as the RFC requires. It also checks two things that decide whether it can proceed: PKCE support, because it must refuse to continue if code_challenge_methods_supported is absent, and later the iss behaviour described below.

Client identity: CIMD, pre-registration and DCR

An MCP client and an MCP server usually have never met, so the client cannot assume it already has a client_id at the server’s authorization server. The specification names three mechanisms and a priority order:

Mechanism How it works Status

Pre-registration

The client has a client_id (and secret, if it is confidential) obtained beforehand, or asks the user to enter one.

Use it first when available.

Client ID Metadata Documents (CIMD)

The client_id is an HTTPS URL, with a path, that points to a JSON document the client hosts, containing at least client_id, client_name and redirect_uris. The authorization server fetches and validates it. The client uses this when the server advertises client_id_metadata_document_supported.

Recommended default for unknown clients.

Dynamic Client Registration (DCR, RFC 7591)

The client registers itself at the server’s registration_endpoint and receives a new client_id.

Deprecated in 2026-07-28; kept for compatibility with servers that lack CIMD.

A client that supports all three tries them in that order. An example client metadata document:

{
  "client_id": "https://app.example.com/oauth/client-metadata.json",
  "client_name": "Example MCP Client",
  "redirect_uris": ["http://127.0.0.1:3000/callback"],
  "grant_types": ["authorization_code"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}

The client_id must equal the document’s URL exactly. Because the AS fetches an attacker-chosen URL, it must guard against server-side request forgery, and CIMD alone cannot prevent someone from impersonating a localhost redirect URI, so authorization servers should warn the user for localhost-only clients. The full rules are in the client registration and security considerations pages of the specification.

The authorization request: PKCE, resource, and iss

MCP clients must use PKCE with the S256 method and must check the AS metadata to confirm it is supported. They must send the resource parameter of RFC 8707 in both the authorization request and the token request, set to the canonical URI of the MCP server — lowercase scheme and host, no fragment, and by convention no trailing slash, for example https://mcp.example.com/mcp — and they must send it whether or not the AS is known to support it. The parameter is what lets the authorization server issue a token whose audience is this server.

The second MCP-specific hardening is against mix-up attacks, in which a malicious AS that a client also talks to tricks it into sending a code from an honest AS to the wrong place. The defence is RFC 9207. Before redirecting the user, the client records the issuer from the metadata it validated, next to the PKCE verifier. Authorization servers should include an iss parameter in every authorization response, including errors, and advertise that by setting authorization_response_iss_parameter_supported to true. When the response arrives and before it sends the code to any token endpoint, the client applies this table:

authorization_response_iss_parameter_supported iss in response Client action

true

present

Compare with the recorded issuer by simple string comparison.

true

absent

Reject the response.

false or absent

present

Compare with the recorded issuer by simple string comparison.

false or absent

absent

Proceed.

Simple string comparison means exactly that: no case folding, default-port elision, trailing-slash or percent-encoding normalisation. On a mismatch the client must not act on or even display error, error_description or error_uri. The specification says a future revision is expected to make iss a MUST for authorization servers, so emit and validate it now.

The token: audience binding, no passthrough

Every request to the MCP server carries the token in the Authorization: Bearer header — never in the query string — and every request must carry it. The server validates the token as a resource server would, and one check is non-negotiable in MCP: it must verify that the token was issued for it as the audience (RFC 8707). A token minted for a different resource is rejected with 401, even if the signature is valid and the user is real. Servers must accept only tokens valid for their own resources and must not accept or forward any other.

That last point is the ban on token passthrough. An MCP server frequently calls other APIs to do its work. It must not take the access token it received from the MCP client and forward it downstream: that would make the token valid at a service it was never issued for, lets a compromised downstream API replay it against the MCP server, and defeats the audit trail. When the server needs to call another API on the user’s behalf it obtains a new token for that API, by token exchange or by calling as itself with client credentials. The MCP security best practices page covers the confused-deputy and passthrough attacks in detail; the threat model for the whole sub-section is on MCP Security.

Errors follow RFC 6750. An invalid or expired token is 401. A valid token without enough privilege is 403 with error="insufficient_scope", the scope the operation needs — all of them in one challenge, not one at a time — and the resource_metadata URL, for example:

HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="files:write",
                         resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"

A client that receives this should attempt step-up authorization: request a new token for the union of the scopes it already had and the new ones, and retry the original request a limited number of times. The server’s per-request authorization can vary the result of tools/list by the scopes on the token, which the tools specification explicitly allows.

Choosing the authorization server

Any OAuth 2.1 authorization server that provides the metadata and behaviours above works with MCP. What differs between products is which optional pieces they support, and the checks below are the ones that decide whether an MCP client can work with yours. Fetch the server’s metadata document and confirm each one.

Capability the MCP client needs Where to confirm it

Authorization code with PKCE S256

code_challenge_methods_supported includes S256.

A way to obtain a client_id

client_id_metadata_document_supported is true (CIMD), or registration_endpoint is present (deprecated DCR), or you pre-register clients.

iss in authorization responses

authorization_response_iss_parameter_supported is true.

A token whose audience is the MCP server

Decode a sample access token: aud equals the server’s canonical URI. If the product does not honour the resource parameter, configure it to add the audience another way (an audience mapper or the API’s identifier), then validate aud on the server.

Scopes matching scopes_supported

The scope names in the token’s scope claim match those in the metadata.

The products this site documents cover the range:

Do not build an authorization server into the MCP server unless you have to. The MCP server’s job is to be a resource server; the Python SDK can act as an authorization server, but running one is a security-sensitive product of its own.

Protecting docs-assistant

Each stack below turns the running example into a resource server that validates a JWT for the audience of the server, requires the docs:read scope, publishes Protected Resource Metadata and answers 401 and 403 with the challenge headers the flow above expects. The issuer and audience come from environment variables, and no secret is involved: JWTs are verified with the authorization server’s public keys, fetched from its JWKS endpoint. The examples were run against tokens signed by a throwaway key served from a local JWKS endpoint, with these results:

Request Spring Security FastAPI Express

No token

401, Bearer resource_metadata="…​"

401, invalid_token, resource_metadata

401, invalid_token, scope, resource_metadata

Valid token, correct audience and scope

200

200

200

Valid signature, wrong audience

401, "The aud claim is not valid"

401

401

Correct audience, missing docs:read

403, insufficient_scope

403, insufficient_scope, resource_metadata

403, insufficient_scope, scope, resource_metadata

Spring Security resource server

This configuration applies to any Spring MVC MCP server, including a Spring AI one (see Spring Boot MCP Servers). Add spring-boot-starter-security-oauth2-resource-server to the POM. The older artifact name spring-boot-starter-oauth2-resource-server is marked deprecated in Spring Boot 4.1 in favour of it. Spring Boot’s resource-server auto-configuration builds the JwtDecoder from properties, including the audience check:

# Issuer, audience and JWKS location come from the environment, never from source
mcp.resource-uri=${MCP_AUDIENCE}
spring.security.oauth2.resourceserver.jwt.issuer-uri=${MCP_ISSUER}
spring.security.oauth2.resourceserver.jwt.audiences=${MCP_AUDIENCE}
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=${MCP_JWKS_URI:${MCP_ISSUER}/protocol/openid-connect/certs}
import static org.springframework.security.config.Customizer.withDefaults;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
class McpSecurityConfig {

    @Bean
    SecurityFilterChain mcpSecurity(HttpSecurity http,
            @Value("${mcp.resource-uri}") String resourceUri,          // canonical URI of this server (MCP_AUDIENCE)
            @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuer) throws Exception {
        return http
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/.well-known/oauth-protected-resource/**").permitAll()
                        .anyRequest().hasAuthority("SCOPE_docs:read"))
                .oauth2ResourceServer(rs -> rs
                        .jwt(withDefaults())                             // signature, exp, iss, aud via Boot properties
                        .protectedResourceMetadata(prm -> prm.protectedResourceMetadataCustomizer(builder -> builder
                                .resource(resourceUri)                   // RFC 9728 document
                                .authorizationServer(issuer)
                                .scope("docs:read")
                                .resourceName("docs-assistant")
                                .tlsClientCertificateBoundAccessTokens(false))))
                .csrf(csrf -> csrf.disable())                            // bearer tokens only, no cookies
                .build();
    }
}

jwt(withDefaults()) enables bearer-token JWT validation; the audiences property adds the aud check that the MCP specification requires, and rejects a token for another resource with 401. The Spring scope-to-authority mapping turns the scope claim into SCOPE_docs:read. protectedResourceMetadata(…​) (Spring Security 7.1) publishes the RFC 9728 document at /.well-known/oauth-protected-resource and its path-suffixed form, and adds the resource_metadata parameter to 401 challenges; the builder’s tlsClientCertificateBoundAccessTokens(false) is set explicitly because the document otherwise advertises true, which would tell clients to expect certificate-bound tokens. CSRF protection is disabled because the endpoint accepts only bearer tokens and never a session cookie. The configuration was run on Spring Boot 4.1.1 with Spring AI 2.0.1. See the Spring Security JWT resource server reference and its Protected Resource Metadata page.

FastAPI and the mcp v2 token verifier

The Python SDK’s MCPServer takes a TokenVerifier and AuthSettings; it then rejects unauthenticated requests, checks the scopes, adds the WWW-Authenticate challenge with the metadata URL, and serves the Protected Resource Metadata itself. The verifier’s only job is to turn a token string into an AccessToken or None:

"""docs-assistant with OAuth 2.1 resource-server protection (mcp v2, mounted in FastAPI)."""
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

import anyio
import jwt
from fastapi import FastAPI
from jwt import PyJWKClient
from mcp.server import MCPServer
from mcp.server.auth.provider import AccessToken
from mcp.server.auth.settings import AuthSettings

ISSUER = os.environ["MCP_ISSUER"]                 # e.g. https://auth.example.com/realms/docs
AUDIENCE = os.environ["MCP_AUDIENCE"]             # canonical URI of this MCP server
JWKS_URI = os.environ.get("MCP_JWKS_URI", f"{ISSUER}/protocol/openid-connect/certs")


class JwtVerifier:
    """Validates signature, issuer, audience and expiry of a JWT access token."""

    def __init__(self) -> None:
        self._jwks = PyJWKClient(JWKS_URI)

    async def verify_token(self, token: str) -> AccessToken | None:
        try:
            # PyJWKClient fetches keys with blocking HTTP: keep it off the event loop.
            signing_key = await anyio.to_thread.run_sync(self._jwks.get_signing_key_from_jwt, token)
            claims = jwt.decode(
                token, signing_key.key, algorithms=["RS256", "ES256"],
                issuer=ISSUER, audience=AUDIENCE,        # rejects tokens minted for another resource
                options={"require": ["exp", "iss", "aud"]},
            )
        except jwt.PyJWTError:
            return None                                   # -> 401 with a WWW-Authenticate challenge
        return AccessToken(
            token=token,
            client_id=str(claims.get("azp", claims.get("client_id", "unknown"))),
            scopes=str(claims.get("scope", "")).split(),
            expires_at=int(claims["exp"]),
            resource=AUDIENCE,
            subject=claims.get("sub"),
            claims=claims,                                # lets the SDK see iss and other claims
        )


mcp = MCPServer(
    "docs-assistant",
    token_verifier=JwtVerifier(),
    auth=AuthSettings(
        issuer_url=ISSUER,
        resource_server_url=AUDIENCE,
        required_scopes=["docs:read"],
        validate_token_resource=True,
    ),
)


@mcp.tool()
async def ping_docs() -> str:
    """Placeholder tool so the protected server has something to list."""
    return "ok"


mcp_app = mcp.streamable_http_app()


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
    async with mcp.session_manager.run():
        yield


app = FastAPI(lifespan=lifespan)
app.mount("/", mcp_app)

Start it with MCP_ISSUER and MCP_AUDIENCE set and uvicorn auth_server:app. The default MCP_JWKS_URI shown is a Keycloak-style path; other servers publish their key location as jwks_uri in the metadata, so set the variable to that. The two lines that matter are the ones that bind the token to this server. jwt.decode(…​, audience=AUDIENCE) refuses a token whose aud is not this server, and validate_token_resource=True makes the SDK’s middleware additionally refuse any token whose reported resource is not resource_server_url; if you leave it unset the SDK warns and does not check, and its documentation says the default becomes True in a future major version. A WWW-Authenticate challenge from this server read Bearer error="invalid_token", …​ resource_metadata="http://127.0.0.1:8003/.well-known/oauth-protected-resource/mcp" on a missing token, and the metadata endpoint returned the resource, the authorization server and scopes_supported. Two details of the example: PyJWKClient fetches and caches keys with a blocking HTTP call, so the verifier runs it in a worker thread (anyio.to_thread.run_sync) rather than stalling the event loop on a JWKS refresh, and the mounted-lifespan requirement is the one explained on MCP inside FastAPI. The same MCPServer works without FastAPI. See the Python SDK documentation and the PyJWT documentation.

Express bearer check for Node.js

@modelcontextprotocol/express provides requireBearerAuth and mcpAuthMetadataRouter, so the TypeScript server needs only an OAuthTokenVerifier. It is written with jose, which fetches and caches the JWKS asynchronously:

import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter, requireBearerAuth } from '@modelcontextprotocol/express';
import { toNodeHandler } from '@modelcontextprotocol/node';
import { createMcpHandler, OAuthError, OAuthErrorCode, type AuthInfo, type OAuthMetadata, type OAuthTokenVerifier } from '@modelcontextprotocol/server';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { createDocsAssistant } from './server.js';

const issuer = process.env.MCP_ISSUER!;                        // e.g. https://auth.example.com/realms/docs
const audience = process.env.MCP_AUDIENCE!;                    // canonical URI of this MCP server
const jwks = createRemoteJWKSet(new URL(process.env.MCP_JWKS_URI ?? `${issuer}/protocol/openid-connect/certs`));

const verifier: OAuthTokenVerifier = {
    async verifyAccessToken(token): Promise<AuthInfo> {
        try {
            const { payload } = await jwtVerify(token, jwks, { issuer, audience });   // signature, iss, aud, exp
            return {
                token,
                clientId: String(payload.azp ?? payload.client_id ?? 'unknown'),
                scopes: String(payload.scope ?? '').split(' ').filter(Boolean),
                expiresAt: payload.exp,
                resource: new URL(audience)
            };
        } catch {
            throw new OAuthError(OAuthErrorCode.InvalidToken, 'Invalid or expired token');
        }
    }
};

// RFC 8414 document of the authorization server, fetched once at start-up; the well-known suffix goes
// between the host and the issuer's path ("path insertion"), or use MCP_AS_METADATA_URL to override
const issuerUrl = new URL(issuer);
const metadataUrl = process.env.MCP_AS_METADATA_URL
    ?? new URL(`/.well-known/oauth-authorization-server${issuerUrl.pathname.replace(/\/$/, '')}`, issuerUrl).href;
const oauthMetadata = (await (await fetch(metadataUrl)).json()) as OAuthMetadata;

const resourceServerUrl = new URL(audience);
const app = createMcpExpressApp();
app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl, scopesSupported: ['docs:read'], resourceName: 'docs-assistant' }));

const node = toNodeHandler(createMcpHandler(createDocsAssistant));
app.all(
    '/mcp',
    requireBearerAuth({
        verifier,
        requiredScopes: ['docs:read'],
        resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(resourceServerUrl)
    }),
    (req, res) => void node(req, res, req.body)
);

app.listen(Number(process.env.PORT ?? 3001), '127.0.0.1');

jwtVerify(…​, { issuer, audience }) is the audience binding. mcpAuthMetadataRouter publishes the RFC 9728 document at /.well-known/oauth-protected-resource/mcp (with the path of MCP_AUDIENCE reflected in the route) and the RFC 8414 document at /.well-known/oauth-authorization-server, and requireBearerAuth sends the challenge that carries resource_metadata. The bearer middleware also refuses a token whose expiresAt is unset, which is why the verifier copies exp. Authenticated requests carry the verified AuthInfo to tool handlers, so a tool can make per-user decisions. The file type-checks with tsc --noEmit (TypeScript 7.0.2) and produced the results in the table above. Add jose to the project’s dependencies alongside the MCP packages. See the TypeScript SDK documentation and the jose library.

The Inspector can perform the OAuth flow itself when a server answers 401 with a challenge, which makes it the quickest way to test an authorization server against these requirements (see Testing & Debugging). MCP hosts store the resulting tokens, and connecting to a protected server from Claude Code or VS Code is described on Connecting Hosts. Optional authorization extensions — OAuth client credentials for machine-to-machine access and enterprise-managed authorization — build on this profile and are documented under MCP authorization extensions.