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.
| Topic | Site page |
|---|---|
Choosing a flow |
|
Authorization code with PKCE (what MCP clients run) |
|
Metadata documents and client registration (RFC 8414, RFC 7591) |
|
Access and refresh tokens, expiry and rotation |
|
JWT validation (signature, issuer, audience, expiry) |
|
Opaque tokens and introspection |
|
Scopes and permissions |
|
Machine-to-machine access |
|
Delegation across services (the alternative to token passthrough) |
|
Sender-constrained tokens |
|
General hardening |
|
Spring Security resource servers and authorization servers |
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.
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 |
Use it first when available. |
Client ID Metadata Documents (CIMD) |
The |
Recommended default for unknown clients. |
Dynamic Client Registration (DCR, RFC 7591) |
The client registers itself at the server’s |
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 |
|---|---|---|
|
present |
Compare with the recorded issuer by simple string comparison. |
|
absent |
Reject the response. |
|
present |
Compare with the recorded issuer by simple string comparison. |
|
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 |
|
A way to obtain a |
|
|
|
A token whose audience is the MCP server |
Decode a sample access token: |
Scopes matching |
The scope names in the token’s |
The products this site documents cover the range:
-
Spring Authorization Server fits when the same organisation builds the MCP server, since it can live in the same ecosystem. The site’s Authorization Server & Social Login page shows how to run it; check its reference for the client-registration options it offers before choosing CIMD, DCR or pre-registration. See the Spring Authorization Server reference.
-
Keycloak is a common self-hosted choice for realms with several MCP servers and existing users; it publishes discovery metadata for each realm. See the Keycloak server administration guide.
-
Microsoft Entra ID is the natural choice when the users and the APIs already live in Microsoft 365 or Azure. See the Microsoft identity platform documentation.
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 |
|
|
|
Valid token, correct audience and scope |
|
|
|
Valid signature, wrong audience |
|
|
|
Correct audience, missing |
|
|
|
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.