MCP Authorization - OAuth 2.1 and Metadata Discovery
This guide explains the MCP authorization spec (for HTTP transports) together with how it maps to OAuth 2.1 and the related RFCs. It is based on the 2026-07-28 version of the spec. Common attacks on authorization and their mitigations are covered in MCP Security Best Practices - Attacks and Mitigations, and machine-to-machine authentication and integration with enterprise IdPs are covered in MCP Authorization Extensions and Enterprise Adoption.
1. What This Document Covers
- Roles and scope of application
- The overall authorization flow
- 401 and Protected Resource Metadata discovery (RFC 9728)
- Authorization server metadata discovery
- Client registration
- Authorization code flow and PKCE
- Resource Indicators (RFC 8707)
- Validating the authorization response (
iss, RFC 9207) - Sending and validating access tokens
- Scope design and step-up
- Refresh tokens
- Error responses
Roles and scope of application
MCP authorization is the mechanism that lets a client, over an HTTP-based transport, make requests to a restricted MCP server on behalf of the resource owner. It maps to the following OAuth 2.1 roles.
| MCP participant | Role in OAuth 2.1 | Responsibility |
|---|---|---|
| MCP server | Resource server | Validates access tokens and returns protected resources |
| MCP client | OAuth client | Makes requests to protected resources on behalf of the user |
| Authorization server | Authorization server | Interacts with the user and issues access tokens for the MCP server. It may be co-hosted with the MCP server or separate. Implementation details are outside the scope of the spec |
Authorization is optional. If you implement it, the approach depends on the transport.
- HTTP-based transports SHOULD follow this spec
- stdio SHOULD NOT follow this spec and instead takes credentials from the environment
- Other transports MUST follow the established security best practices for their protocol
The official MCP tutorial strongly recommends authorization in cases such as: accessing user-specific data (email, documents, databases), wanting to audit who performed which operation, accessing APIs that require user consent, enterprise environments with strict access control, and wanting per-user rate limiting or usage tracking.
The spec combines OAuth 2.1 (IETF draft), Bearer Token (RFC 6750), Authorization Server Metadata (RFC 8414), Dynamic Client Registration (RFC 7591), Resource Indicators (RFC 8707), Protected Resource Metadata (RFC 9728), Authorization Server Issuer Identification (RFC 9207), Client ID Metadata Documents, and OpenID Connect Discovery, restricting each to a subset of its features.
The overall authorization flow
The flow from an unauthenticated request to communication with an access token is as follows.
401 and Protected Resource Metadata discovery
An MCP server MUST implement OAuth 2.0 Protected Resource Metadata (RFC 9728). The metadata includes authorization_servers, which contains at least one authorization server. The server advertises the location of the metadata in either (or both) of the following ways.
WWW-Authenticateheader: put the URL in the401 Unauthorizedresponse asresource_metadata- Well-Known URI: serve it at
/.well-known/oauth-protected-resource. There is a variant that follows the MCP endpoint path (forhttps://example.com/public/mcp, it ishttps://example.com/.well-known/oauth-protected-resource/public/mcp) and a root variant
Clients MUST support both. If WWW-Authenticate contains resource_metadata, use that URL; otherwise, try the Well-Known URI with the path first, then at the root.
If the metadata lists multiple authorization servers, the client decides which one to use (RFC 9728 section 7.6). Each is an independent OAuth 2.0 authorization server, and a client ID is specific to the authorization server that issued it. The client keeps registration information and tokens separately for each authorization server, and MUST NOT assume that credentials for one authorization server are valid at another.
Authorization server metadata discovery
An authorization server MUST provide at least one of RFC 8414 Authorization Server Metadata and OpenID Connect Discovery 1.0. Clients MUST support both and try the endpoints in the following order.
When the issuer has a path (https://auth.example.com/tenant1):
https://auth.example.com/.well-known/oauth-authorization-server/tenant1(OAuth 2.0 path insertion)https://auth.example.com/.well-known/openid-configuration/tenant1(OIDC path insertion)https://auth.example.com/tenant1/.well-known/openid-configuration(OIDC path appending)
When the issuer has no path (https://auth.example.com):
https://auth.example.com/.well-known/oauth-authorization-serverhttps://auth.example.com/.well-known/openid-configuration
For the retrieved metadata, verify that the issuer in the document exactly matches the issuer used to build the discovery URL (RFC 8414 section 3.3). If it does not match, the client MUST NOT use the metadata. For example, if a document fetched from https://attacker.example/.well-known/oauth-authorization-server says "issuer": "https://honest.example", reject it.
Client registration
Before the authorization flow, the client obtains a client_id. The spec defines three methods plus manual user entry as a last resort, and a client that supports all of them SHOULD follow this order of priority.
| Priority | Method | When to use |
|---|---|---|
| 1 | Pre-registration | The client and server already have a relationship. Embed the client ID (and credentials if needed) or have the user enter it |
| 2 | Client ID Metadata Documents (CIMD) | The authorization server indicates support with client_id_metadata_document_supported: true in its metadata. The most common case for unrelated MCP clients and servers |
| 3 | Dynamic Client Registration (DCR) | The authorization server has a registration_endpoint. Deprecated, and kept only for backward compatibility with authorization servers that do not support CIMD. Deprecated in the 2026-07-28 revision (PR #2858); the earliest removal is the first revision released on or after 2027-07-28. Removal is a Core Maintainers decision, not automatic |
| 4 | Have the user enter it | No other means is available |
With CIMD, the client uses an HTTPS URL as its client_id and hosts its client metadata as JSON at that URL. When an authorization server sees a URL-form client_id, it fetches and validates that document.
- Client: the
client_iduses thehttpsscheme and includes a path (for examplehttps://example.com/client.json). The document contains at leastclient_id,client_name, andredirect_uris, andclient_idexactly matches the document URL - Authorization server: MUST verify that the document's
client_idexactly matches the URL, and MUST check theredirect_uriin the authorization request against the values in the document. It also validates the JSON structure and required fields, and SHOULD cache according to HTTP cache headers
A CIMD client_id is a self-hosted HTTPS URL, so it stays portable if the authorization server changes. Registration information obtained through DCR and pre-registered credentials MUST be kept bound to the issuer of the authorization server that issued them, and the client MUST re-register if the authorization server changes. Do not reuse credentials from another authorization server. If an authorization server that does not match the pre-registered credentials is presented, fail with an error instead of trying silently (SHOULD).
When using DCR, the client MUST specify application_type appropriately. If omitted, OIDC defaults to web, which can conflict with native redirect URIs. Use native for native apps (desktop, mobile, CLI, and local web apps running on localhost) and web for remote web apps.
Authorization code flow and PKCE
User authorization uses the authorization code flow through a browser. The client MUST implement PKCE and MUST use the S256 method whenever possible. OAuth 2.1 and PKCE do not define how a client learns whether the authorization server supports PKCE, so MCP clients confirm it through the authorization server metadata.
- OAuth 2.0 Authorization Server Metadata: if
code_challenge_methods_supportedis absent, PKCE is not supported, and the client MUST NOT proceed - OpenID Connect Discovery: OpenID Provider metadata does not define this field, but many OPs include it. The client checks for the field's presence and MUST NOT proceed if it is absent. Authorization servers that provide OIDC Discovery MUST include
code_challenge_methods_supportedfor compatibility with MCP
Redirect URIs MUST be either localhost or HTTPS. All authorization server endpoints MUST be served over HTTPS. The client SHOULD register its redirect URI with the authorization server, use state, and discard the response if it does not match. The authorization server MUST validate redirect URIs by exact match against the registered values.
Resource Indicators (RFC 8707)
The client MUST implement the resource parameter, which explicitly states the resource the token is intended for.
- Include it in both the authorization request and the token request
- Point it at the MCP server the client intends to use
- Use the canonical URI of the MCP server (RFC 8707 section 2)
MCP clients always send this parameter, regardless of whether the authorization server supports resource. Sending it means that future support will bind tokens to their audience, and the server-side audience validation will work.
The canonical URI is the resource identifier that matches resource in RFC 9728. Use the most specific one possible. A lowercase scheme and host is the canonical form, but for robustness, accept uppercase as well (SHOULD).
| Example | Verdict |
|---|---|
https://mcp.example.com/mcp | Valid |
https://mcp.example.com | Valid |
https://mcp.example.com:8443 | Valid |
https://mcp.example.com/server/mcp | Valid (when a path is needed to identify an individual server) |
mcp.example.com | Invalid (no scheme) |
https://mcp.example.com#fragment | Invalid (contains a fragment) |
Standardize on the form without a trailing slash unless it is semantically significant (SHOULD).
Validating the authorization response (iss, RFC 9207)
Before the redirect, the client MUST record the issuer from the verified metadata of the selected authorization server and bind it to the same per-request record as the PKCE code_verifier (and state). This validation depends on the recorded value being genuine. Using an issuer obtained from an unverified source provides no protection.
Authorization servers SHOULD include iss in the authorization response (including error responses). An authorization server that does so indicates it with authorization_response_iss_parameter_supported: true in its metadata. Before sending the authorization code to the token endpoint, the client MUST validate as follows.
authorization_response_iss_parameter_supported | iss in the response | Client behavior |
|---|---|---|
true | Present | Do a simple string comparison with the recorded issuer |
true | Absent | Reject the response |
false or unset | Present | Do a simple string comparison with the recorded issuer |
false or unset | Absent | Proceed as is |
After decoding iss from application/x-www-form-urlencoded, the client MUST NOT normalize it before comparing: no case normalization of the scheme or host, no omitting default ports, no trailing-slash handling, and no percent-encoding normalization. This validation applies equally to error responses, and on a mismatch the client MUST NOT process or display error, error_description, or error_uri. A future revision is expected to raise authorization servers' inclusion of iss from SHOULD to MUST, so implementing both inclusion and validation now is recommended.
Sending and validating access tokens
Clients send the token in the Authorization header, following OAuth 2.1 section 5.
Authorization: Bearer <access-token>
- Authorization MUST be included in every HTTP request from the client to the server
- Access tokens MUST NOT be included in the URI query string
- MCP clients MUST NOT send the MCP server any token other than one issued by the MCP server's authorization server
As a resource server, the MCP server MUST validate access tokens following OAuth 2.1 section 5.2. In particular, it MUST validate that the token was issued for itself, following RFC 8707. If validation fails, it follows the error handling in OAuth 2.1 section 5.3, responding with HTTP 401 for an invalid or expired token.
- The MCP server MUST accept only tokens valid for its own resource
- It MUST NOT accept or forward other tokens. To call a downstream API, the MCP server itself becomes an OAuth client and uses a separate token issued by the downstream authorization server. "Token passthrough", which hands the received token on as is, is prohibited (see MCP Security Best Practices - Attacks and Mitigations for details)
Scope design and step-up
The server SHOULD include scope in the WWW-Authenticate header to tell the client which scopes are needed to access the resource. In line with the principle of least privilege, this steers clients away from requesting excessive permissions.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
scope="files:read"
The scopes in a challenge may equal scopes_supported, or be a subset, a superset, or a different set; the client MUST NOT assume any of these relationships. The client MUST treat the scopes in the challenge as authoritative for the current operation.
For the initial authorization, the client SHOULD choose scopes in the following order of priority.
- Use
scopefrom theWWW-Authenticateof the first 401 - If absent, use all of
scopes_supportedfrom the Protected Resource Metadata (if there is noscopes_supported, omitscope)
Design scopes_supported to represent the minimum scopes needed for basic functionality, and request additional scopes incrementally through the step-up flow.
insufficient_scope at runtime: If the token is valid but lacks the scopes for the operation, the server SHOULD respond as follows.
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",
error_description="File write permission required for this operation"
- Include the scopes required for the current operation together in a single challenge. Adding them one at a time and making the client retry causes many authorization round trips for one operation and a poor experience
- Be consistent within the server about how scopes are included
- MUST take the scope hierarchy (broader scopes include narrower ones) into account when deciding whether a token is sufficient
The client responds to scope errors with step-up authorization (SHOULD, when acting on behalf of a user).
- Parse the error information from
WWW-Authenticateand so on - Compute the union of the previously requested scopes and the scopes in this challenge. This keeps already-granted permissions from being lost even with per-operation challenges. This accumulation is the client's responsibility
- Start re-authorization with that set
- Retry the original request with the new authorization. Limit retries to a few attempts, and treat anything beyond that as a permanent authorization failure
The client SHOULD set a retry limit and record its attempts so that scope upgrades do not fail repeatedly for the same combination of resource and operation. Clients that act with their own credentials (client_credentials) MAY attempt step-up or abort immediately.
Refresh tokens
A client that requests a refresh token follows these rules.
- MUST keep the refresh token confidential, both in transit and at rest
- SHOULD include
refresh_tokeningrant_typesin the client metadata - MAY add
offline_accesstoscopein authorization and token requests if it appears inscopes_supportedin the authorization server metadata - MUST NOT assume a refresh token will be issued. Whether to issue one is at the authorization server's discretion
Because refresh tokens are not a requirement of the resource, an MCP server SHOULD NOT include offline_access in the scope of WWW-Authenticate or in scopes_supported of the Protected Resource Metadata. For public clients, the authorization server MUST rotate refresh tokens.
Error responses
The server MUST respond to authorization errors with an appropriate HTTP status code.
| Status | Meaning | Use |
|---|---|---|
| 401 | Unauthorized | Authorization is required, or the token is invalid |
| 403 | Forbidden | The scope is invalid, or permissions are insufficient |
| 400 | Bad Request | The authorization request is malformed |
The official tutorial also recommends returning only a generic message to the client for error details, and keeping the detailed reason in server-side logs together with a correlation ID.
2. Authorization Flow and Related Standards
| Step | Standard | In MCP |
|---|---|---|
| Resource server metadata | RFC 9728 | Required for MCP servers |
| Authorization server metadata | RFC 8414 / OpenID Connect Discovery | The authorization server provides at least one; clients support both |
| Client identification | Client ID Metadata Documents / DCR (RFC 7591) | CIMD is preferred; DCR is deprecated and kept for compatibility |
| Authorization request | OAuth 2.1 + PKCE | PKCE (S256) for all clients |
| Targeting the token | RFC 8707 | Always send resource; the server validates the audience |
| Verifying the issuer | RFC 9207 | Compare iss with the recorded issuer |
| Sending the token | RFC 6750 (Bearer) | Authorization header. Query strings are prohibited |
Key points by implementer
| Implementer | Main responsibilities |
|---|---|
| MCP server | Provides RFC 9728 metadata. Returns 401 with resource_metadata and scope for unauthenticated requests. Validates the token's signature, expiry, issuer, and audience. Returns missing scopes with 403 insufficient_scope. Does not forward tokens |
| MCP client | Discovers through both WWW-Authenticate and Well-Known. Validates the issuer in the authorization server metadata. Confirms PKCE (S256) support. Always sends resource. Validates iss. Stores tokens protected and sends them in the header. Steps up with the union of scopes and a limited number of attempts |
| Authorization server | Provides RFC 8414 or OIDC Discovery metadata. Advertises code_challenge_methods_supported. Validates redirect URIs by exact match. If using CIMD, considers SSRF and domain trust policies. Keeps access tokens short-lived and rotates refresh tokens for public clients |
3. Common Pitfalls for Developers
Trying to implement OAuth for a stdio server
A stdio server does not follow this spec and takes credentials from the environment. The OAuth authorization flow is for remote HTTP servers. For handling credentials of local servers, see Operating Local MCP Servers Safely.
Validating only the token's signature and expiry, and not the audience
Even if the signature is valid, you must not accept a token meant for another resource. Also do not allow generic audiences such as account that an IdP adds, or common values like api. Compare the audience against your own canonical URI. Some IdPs require configuration to put the value received in resource into aud. Keep a fixed-value audience to test configurations only.
Writing your own validation logic
Leave signature validation, JWKS fetching and key-rotation handling, and claim validation to a proven library. Write yourself only the service-specific decisions such as audience, issuer, and scope.
Inconsistent resource notation
If https://mcp.example.com/ and https://mcp.example.com are mixed across the client, the authorization server, and the server's validation, the audience will not match. Standardize on no trailing slash, and apply the same rule to the authorization server's audience setting and the server-side audience comparison.
Not validating the issuer in the authorization server metadata
If you do not check that the discovery URL and the issuer in the document match, an attacker's server can pose as an honest authorization server. If the recorded issuer cannot be trusted, the iss validation is meaningless too.
Proceeding without confirming PKCE support
With an authorization server that lacks code_challenge_methods_supported, PKCE cannot be used, and authorization code interception and injection cannot be prevented. The client must not proceed.
Normalizing iss before comparing
Comparing iss is a simple string comparison. If you fix up trailing slashes or case before comparing, the validation is weakened.
Listing every scope in scopes_supported
This asks the user for overly broad consent on the first authorization and widens the damage if a token leaks. Keep scopes_supported to the minimum set needed for basic functionality, and make privileged operations step up through 403 insufficient_scope.
Requesting scope upgrades one at a time
If you do not combine the scopes an operation needs into a single challenge, the user has to consent many times. On the client side, re-authorizing without computing the union loses earlier permissions and can cause an infinite loop.
Listing offline_access in the MCP server's metadata
Refresh tokens are not a requirement of the resource. The client requests them as needed.
Putting tokens in query strings or logs
Always send access tokens in the header, and keep the Authorization header, tokens, codes, and secrets out of logs. Prevent query strings from leaking into logs, and mask sensitive fields in structured logs.
Leaving DCR open without authentication
DCR without authentication means anyone can register an arbitrary client with the authorization server. Decide on restrictions for allowed hosts, review, and registration auditing. Prefer CIMD for new deployments.
Not distinguishing issuers in multi-tenant setups
Unless it is an intentional multi-tenant setup, pin to a single issuer (tenant). Reject tokens from other realms even if the same authorization server signed them.
4. Code Examples
401 response and Protected Resource Metadata
In the response to an unauthenticated request, tell the client where the metadata is and which scopes are needed.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
scope="mcp:tools-basic"
Here is an example of the metadata (/.well-known/oauth-protected-resource).
{
"resource": "https://mcp.example.com/mcp",
"authorization_servers": ["https://auth.example.com"],
"scopes_supported": ["mcp:tools-basic"]
}
Authorization server metadata (MCP-relevant fields)
{
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token",
"code_challenge_methods_supported": ["S256"],
"client_id_metadata_document_supported": true,
"authorization_response_iss_parameter_supported": true,
"scopes_supported": ["mcp:tools-basic", "mcp:tools-write", "offline_access"]
}
Client ID Metadata Document
This is the document the client hosts at https://app.example.com/oauth/client-metadata.json. client_id must exactly match this URL.
{
"client_id": "https://app.example.com/oauth/client-metadata.json",
"client_name": "Example MCP Client",
"client_uri": "https://app.example.com",
"logo_uri": "https://app.example.com/logo.png",
"redirect_uris": [
"http://127.0.0.1:3000/callback",
"http://localhost:3000/callback"
],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}
Authorization request and token request
Include resource, code_challenge, and scope in the authorization request (line breaks added for readability).
GET https://auth.example.com/authorize
?response_type=code
&client_id=https%3A%2F%2Fapp.example.com%2Foauth%2Fclient-metadata.json
&redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2Fcallback
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
&resource=https%3A%2F%2Fmcp.example.com%2Fmcp
&scope=mcp%3Atools-basic
&state=af0ifjsldkj
Include resource in the token request as well.
POST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2Fcallback
&client_id=https%3A%2F%2Fapp.example.com%2Foauth%2Fclient-metadata.json
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
&resource=https%3A%2F%2Fmcp.example.com%2Fmcp
Client: PKCE, metadata and iss validation, and step-up (TypeScript)
This shows validating the authorization server metadata, confirming PKCE support, validating the authorization response including iss, and the union of scopes.
import { createHash, randomBytes } from "node:crypto";
export interface AsMetadata {
issuer: string;
code_challenge_methods_supported?: string[];
authorization_response_iss_parameter_supported?: boolean;
}
/** Recorded for each authorization request. Created before the redirect. */
export interface AuthRequestRecord {
issuer: string; // The issuer taken from the verified metadata
issParamSupported: boolean;
state: string;
verifier: string;
}
/** Confirm that the issuer used to build the discovery URL exactly matches the issuer in the document. */
export function assertIssuerMatches(expectedIssuer: string, meta: AsMetadata): void {
if (meta.issuer !== expectedIssuer) {
throw new Error(`issuer mismatch: expected ${expectedIssuer}, got ${meta.issuer}`);
}
}
/** If code_challenge_methods_supported is absent, abort as PKCE is not supported. */
export function assertPkceSupported(meta: AsMetadata): void {
if (!meta.code_challenge_methods_supported?.includes("S256")) {
throw new Error("authorization server does not advertise S256 PKCE support");
}
}
export function newPkce(): { verifier: string; challenge: string } {
const verifier = randomBytes(32).toString("base64url"); // 43 characters
const challenge = createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
/**
* Validate the authorization response. Call this before sending the authorization code to the token endpoint.
* Do not normalize iss; do a simple string comparison. This also applies to error responses.
*/
export function validateAuthorizationResponse(
params: URLSearchParams,
rec: AuthRequestRecord,
): { code: string } {
const iss = params.get("iss"); // URLSearchParams decodes form-urlencoded
if (iss === null) {
if (rec.issParamSupported) throw new Error("missing iss in authorization response");
} else if (iss !== rec.issuer) {
// On a mismatch, do not display or process error or error_description.
throw new Error("iss mismatch: possible mix-up attack");
}
if (params.get("state") !== rec.state) throw new Error("state mismatch");
const error = params.get("error");
if (error !== null) throw new Error(`authorization failed: ${error}`);
const code = params.get("code");
if (code === null) throw new Error("missing code");
return { code };
}
/** Step-up: compute the union of the previously requested scopes and the scopes in this challenge. */
export function stepUpScopes(previous: string[], challengeScope: string): string[] {
const challenged = challengeScope.split(" ").filter(Boolean);
return [...new Set([...previous, ...challenged])];
}
Server: token validation middleware (Go)
It validates the signature, expiry, issuer, and audience, and returns missing scopes with 403 insufficient_scope. JWT validation uses github.com/golang-jwt/jwt/v5, and key retrieval (JWKS fetching and rotation) is passed in as a jwt.Keyfunc.
package mcpauth
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
jwt.RegisteredClaims
Scope string `json:"scope"`
}
type ctxKey struct{}
// FromContext returns the validated claims. When running a tool, use sub to identify the user.
func FromContext(ctx context.Context) (*Claims, bool) {
c, ok := ctx.Value(ctxKey{}).(*Claims)
return c, ok
}
type Verifier struct {
Resource string // Canonical URI of this MCP server (the value expected as the audience)
Issuer string // Issuer of the trusted authorization server (pinned to one)
MetadataURL string // URL of the Protected Resource Metadata
Keyfunc jwt.Keyfunc
}
func (v *Verifier) challenge(w http.ResponseWriter, status int, extra string) {
h := fmt.Sprintf(`Bearer resource_metadata=%q`, v.MetadataURL)
if extra != "" {
h += ", " + extra
}
w.Header().Set("WWW-Authenticate", h)
w.WriteHeader(status)
}
// Require demands a valid access token.
// The first 401 includes the required scopes (the minimum set) as scope.
func (v *Verifier) Require(initialScope string, next http.Handler) http.Handler {
parser := jwt.NewParser(
jwt.WithValidMethods([]string{"RS256", "ES256"}), // Restrict signing algorithms (reject none)
jwt.WithIssuer(v.Issuer),
jwt.WithAudience(v.Resource), // Accept only tokens addressed to this server
jwt.WithExpirationRequired(),
)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization") // Accept the token only from the header (never look at the query)
raw, ok := strings.CutPrefix(auth, "Bearer ")
if !ok || raw == "" {
v.challenge(w, http.StatusUnauthorized, fmt.Sprintf("scope=%q", initialScope))
return
}
claims := &Claims{}
if _, err := parser.ParseWithClaims(raw, claims, v.Keyfunc); err != nil {
// Do not return the detailed reason to the client; keep it in server-side logs.
v.challenge(w, http.StatusUnauthorized, `error="invalid_token"`)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, claims)))
})
}
// RequireScope returns 403 insufficient_scope if the scopes required for the operation are missing.
// Include the scopes required for the current operation together in a single challenge.
func (v *Verifier) RequireScope(next http.Handler, required ...string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := FromContext(r.Context())
if !ok {
v.challenge(w, http.StatusUnauthorized, "")
return
}
have := map[string]bool{}
for _, s := range strings.Fields(claims.Scope) {
have[s] = true
}
for _, s := range required {
if !have[s] {
v.challenge(w, http.StatusForbidden, fmt.Sprintf(
`error="insufficient_scope", scope=%q`, strings.Join(required, " ")))
return
}
}
next.ServeHTTP(w, r)
})
}
If you have a scope hierarchy (for example, admin includes read), expand it when building have.
Server: configuring ASP.NET Core (C#)
This combines the MCP C# SDK with ASP.NET Core's standard JWT Bearer authentication (the official tutorial's setup, trimmed to the essentials). It restricts the audience to the server's own URI.
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using ModelContextProtocol.AspNetCore.Authentication;
var builder = WebApplication.CreateBuilder(args);
var serverUrl = "https://mcp.example.com/";
var authorizationServerUrl = "https://auth.example.com/";
builder.Services.AddAuthentication(options =>
{
options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.Authority = authorizationServerUrl;
options.RequireHttpsMetadata = true; // Require HTTPS in production
var audience = serverUrl.TrimEnd('/');
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = authorizationServerUrl,
// Accept only tokens addressed to this server. Generic audiences such as "account" are not allowed.
AudienceValidator = (audiences, _, _) =>
audiences != null && audiences.Any(a =>
string.Equals(a.TrimEnd('/'), audience, StringComparison.OrdinalIgnoreCase)),
};
})
.AddMcp(options =>
{
// Published as Protected Resource Metadata (RFC 9728)
options.ResourceMetadata = new()
{
Resource = serverUrl,
AuthorizationServers = { authorizationServerUrl },
ScopesSupported = ["mcp:tools-basic"],
};
});
builder.Services.AddAuthorization();
builder.Services.AddMcpServer().WithHttpTransport();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapMcp().RequireAuthorization();
app.Run();
5. Summary
- The MCP server is an OAuth 2.1 resource server and the MCP client is an OAuth client. Authorization is optional: HTTP SHOULD follow the spec, and stdio takes credentials from the environment
- For unauthenticated requests, return 401 with
WWW-Authenticate(resource_metadata,scope). RFC 9728 Protected Resource Metadata is required for MCP servers, and clients discover it through bothWWW-Authenticateand Well-Known - Support both RFC 8414 and OIDC Discovery for authorization server metadata, and validate that the
issuerin the document matches the value used for discovery - For client registration, prefer pre-registration, then CIMD, then DCR (deprecated). Keep credentials bound to the authorization server that issued them
- The authorization code flow requires PKCE (
S256); abort ifcode_challenge_methods_supportedis absent. Includeresourcein both the authorization and token requests, and have the server validate the audience - Compare the
issin the authorization response with the recorded issuer as a string, without normalization. PKCE alone cannot prevent mix-up attacks - Attach the token to every request with
Authorization: Bearer, never in the query. The server accepts only tokens addressed to itself and does not forward other tokens - Start with minimal scopes. If more are needed, return the required scopes together in a 403
insufficient_scope. The client steps up with the union of scopes and a limited number of attempts - Keep refresh tokens confidential, and do not list
offline_accessin the MCP server's metadata. Leave validation to a library, and configure only the audience, issuer, and scopes yourself
Related Documents
- MCP Security Best Practices - Attacks and Mitigations
- MCP Authorization Extensions and Enterprise Adoption
- OpenID Connect and OAuth 2.0
- Bearer Authentication
References
- Authorization (2026-07-28)
- Authorization Server Discovery
- Client Registration
- Authorization Security Considerations
- Understanding Authorization in MCP
- RFC 9728: OAuth 2.0 Protected Resource Metadata
- RFC 8707: Resource Indicators for OAuth 2.0
- RFC 9207: Authorization Server Issuer Identification
- OAuth Client ID Metadata Document (IETF draft)
- OAuth 2.1 (IETF draft)