Community-maintained FDE reference. Not an official Vercel or Anthropic project. About this project
Authorization flows
TL;DR: MCP authorization is OAuth 2.1, and it applies to HTTP transports only: a stdio server is a local subprocess that takes its credentials from the environment, not from an OAuth flow. Your MCP server is an OAuth 2.1 resource server; the client obtains an access token from an authorization server via the Authorization Code grant with PKCE and presents it as an Authorization: Bearer header on every request, audience-bound to your specific server (RFC 8707). The client discovers where to authenticate from the server itself (RFC 9728). On Vercel the whole resource-server side is two pieces of mcp-handler: withMcpAuth wraps the route handler and enforces the token, and protectedResourceHandler serves the discovery metadata. This page is the end-to-end flow; for the operator checkboxes see the security checklist, for who the token represents see Where the principal comes from, and for the server’s own upstream credentials see credential brokering.
What this covers (and what it doesn’t)
This is client to server authorization: the client proving, on a user’s behalf, that it may call a protected MCP server. It is distinct from two other flows this repo documents:
- the server’s credentials to its upstream backend: a separate token, covered by credential brokering;
- url-mode elicitation, where a server obtains third-party authorization out of band.
Authorization is OPTIONAL in MCP. When supported, HTTP-based implementations SHOULD conform to the spec’s flow, and stdio implementations SHOULD NOT use it (environment credentials instead). This repo’s posture is stricter than the spec’s floor: a deployed Vercel Function is a public URL, so treat auth on a remote MCP server as mandatory. The spec makes it a SHOULD; your threat model makes it a MUST.
The flow
Diagram source (Mermaid)
sequenceDiagram
participant C as Client
participant M as MCP Server (Resource Server)
participant A as Authorization Server
C->>M: request without token
M-->>C: 401 + WWW-Authenticate (resource_metadata, scope)
C->>M: GET Protected Resource Metadata (RFC 9728)
M-->>C: authorization_servers + scopes
C->>A: GET AS metadata (RFC 8414 or OIDC discovery)
A-->>C: endpoints + PKCE support
Note over C: generate PKCE (S256), pick scopes, set resource, record issuer
C->>A: authorize (code_challenge, resource, scope)
A-->>C: authorization code + iss (after user consent)
Note over C: validate iss against recorded issuer (RFC 9207)
C->>A: token (code_verifier, resource)
A-->>C: access token (+ refresh)
C->>M: request + Authorization Bearer + MCP-Protocol-Version
M-->>C: response (after validating token audience)1. Discovery: find the authorization server
The client makes an unauthenticated request and gets back 401 Unauthorized. The server MUST implement OAuth 2.0 Protected Resource Metadata (RFC 9728), and its metadata MUST include an authorization_servers field naming at least one authorization server. The location of that metadata is advertised one of two ways (the client MUST support both):
- a
WWW-Authenticateheader on the 401 carryingresource_metadata(the metadata URL), which servers SHOULD augment with ascopehint; or - a well-known URI fallback:
/.well-known/oauth-protected-resource, either at the root or in the path-suffixed form (/.well-known/oauth-protected-resource/api/mcpfor a server at/api/mcp).
The client then fetches the authorization server’s own metadata: the AS MUST provide OAuth 2.0 Authorization Server Metadata (RFC 8414) or OpenID Connect Discovery 1.0, and the client MUST try both well-known endpoint families in the spec’s priority order.
2. Client registration
MCP assumes clients and servers usually have no prior relationship. The 2026-07-28 revision reorders the registration mechanisms: Client ID Metadata Documents are the preferred path, and Dynamic Client Registration is formally deprecated (PR #2858; it appears in the spec’s deprecated-features registry and stays functional for at least a twelve-month window). A client supporting all mechanisms SHOULD try, in order:
- Pre-registered credentials it already holds for this authorization server.
- OAuth Client ID Metadata Documents (CIMD): the client uses an HTTPS URL as its
client_id, pointing at a JSON document of its metadata (at minimumclient_id,client_name,redirect_uris). Advertised byclient_id_metadata_document_supportedin AS metadata; authorization servers and clients SHOULD support it. - Dynamic Client Registration (RFC 7591):
POST /register, deprecated in favor of CIMD; retained for backwards compatibility with authorization servers that do not support metadata documents. - Prompting the user for client details, as the last resort.
Two registration rules are new in 2026-07-28:
application_typeis mandatory in DCR (SEP-837): a client registering dynamically MUST specify an appropriateapplication_type:"native"for desktop, mobile, CLI, and localhost-served apps;"web"for remote browser-based apps. Omitting it defaults to"web"under OIDC, which conflicts with native-style redirect URIs; clients must be prepared for registration rejections on redirect-URI constraints and surface them meaningfully.- Client credentials are issuer-bound (SEP-2352): a client MUST key persisted credentials by the authorization server’s
issueridentifier, MUST NOT reuse credentials issued by one authorization server against another, and MUST re-register when the server’s advertised authorization server changes. CIMD identities are the exception: an HTTPSclient_idis portable across authorization servers because each one resolves it on demand.
3. Authorization Code + PKCE
The client MUST implement PKCE and MUST verify the AS advertises it (code_challenge_methods_supported present in the metadata) before proceeding; if the field is absent, the client MUST refuse to continue. The S256 challenge method is required when the client is technically capable of it. The client generates a code_verifier/code_challenge pair, opens the browser to the authorize endpoint (with code_challenge, the resource parameter, and the chosen scope), the user consents, and the AS redirects back with an authorization code. The client exchanges the code (plus code_verifier and resource) for an access token, usually with a refresh token. Redirect URIs MUST be registered and validated exactly; use and verify a state parameter.
2026-07-28 adds authorization server issuer identification (RFC 9207) to this leg (SEP-2468). Before redirecting, the client MUST record the issuer value from the authorization server’s validated metadata in the same per-request record as the PKCE verifier (and state). The AS SHOULD include the iss parameter in authorization responses and, when it does, MUST advertise authorization_response_iss_parameter_supported: true in its metadata. When iss is present in the response, the client MUST compare it to the recorded issuer with a simple string comparison (no normalization) before sending the authorization code to any token endpoint, and refuse on mismatch; when the AS advertised support and iss is absent, the client MUST reject the response. This closes mix-up attacks where one authorization server answers for another.
4. Using the token
The access token goes in the Authorization: Bearer <token> header on every HTTP request and MUST NOT appear in the URI query string. Requests also carry MCP-Protocol-Version plus the routing headers Mcp-Method and Mcp-Name that 2026-07-28 requires on Streamable HTTP POSTs (see Transports). The server MUST validate the token on every request and return 401 for invalid or expired tokens. There is no longer a session to confuse with authentication: 2026-07-28 removed protocol-level sessions and the Mcp-Session-Id header outright (SEP-2567), so every request authenticates itself, which is the model this repo always recommended on serverless (see Serverless sessions). Wire status: the pinned stack (mcp-handler 2.1.1 on @modelcontextprotocol/server 2.0.0) serves the 2026-07-28 contract natively over Streamable HTTP and falls back to stateless 2025-11-25 Streamable HTTP for legacy clients; the SDK Client defaults to that legacy handshake unless you opt in to modern version negotiation, so the examples’ in-memory test suites exercise only the legacy path.
5. Audience binding: the security keystone
The client MUST send the resource parameter (RFC 8707), the canonical URI of the target MCP server (for example https://my-mcp-server.vercel.app/api/mcp), in both the authorize and token requests, so the issued token is bound to that one server. The server MUST validate that a presented token was issued specifically for it, MUST reject tokens that were not, and MUST NOT accept or transit tokens meant for anything else. Forwarding the client’s token upstream (“token passthrough”) is explicitly forbidden: it creates the confused-deputy problem, where the upstream API trusts a token it never should have seen. The server’s upstream credential is a separate token (see credential brokering and Trust boundaries).
Scopes and step-up authorization
Follow least privilege: use the scope from the 401’s WWW-Authenticate if present, else fall back to scopes_supported from the resource metadata. When a valid token lacks a permission at runtime, the server SHOULD respond 403 Forbidden with WWW-Authenticate: Bearer error="insufficient_scope", scope="...", and the client SHOULD perform a step-up authorization: re-authorize for the larger scope set and retry, with a retry limit. Scopes escalate when actually needed, not up front. See the consent half of this contract in Consent UX and Consent & user approval.
The Vercel implementation
mcp-handler (the package behind every server in this repo’s examples (examples/minimal-server, in the repository)) ships the resource-server side of everything above. Two route files cover it. First, wrap the MCP handler:
import { createMcpHandler, withMcpAuth } from "mcp-handler";import type { AuthInfo } from "@modelcontextprotocol/server";
// The canonical resource this deployment serves: the RFC 8707 audience// tokens are bound to. Fixed configuration, never read from the request.// Set MCP_RESOURCE_URL to the public endpoint, e.g.// https://my-server.vercel.app/api/mcp (no trailing slash, no fragment).const CANONICAL_RESOURCE = process.env.MCP_RESOURCE_URL!;// withMcpAuth takes the ORIGIN (scheme, host, port) and appends// resourceMetadataPath to it; passing the full endpoint URL would advertise// /api/mcp/.well-known/... instead.const CANONICAL_RESOURCE_ORIGIN = new URL(CANONICAL_RESOURCE).origin;
const handler = createMcpHandler(configureServer, { serverInfo: { name: "my-server", version: "1.0.0" },});
const verifyToken = async ( req: Request, bearerToken?: string,): Promise<AuthInfo | undefined> => { if (!bearerToken) return undefined; // Validate signature, issuer, and expiry here (verify a JWT against the // AS JWKS, or introspect the token), then compare the token's audience // (the JWT "aud" claim, or the introspection response) against // CANONICAL_RESOURCE. A token minted for any other resource is rejected // even when everything else about it is valid. Return undefined for // anything that fails. return { token: bearerToken, clientId: "client-abc", scopes: ["tools:read"], resource: new URL(CANONICAL_RESOURCE), expiresAt: 1893456000, // seconds since epoch };};
const authHandler = withMcpAuth(handler, verifyToken, { required: true, requiredScopes: ["tools:read"], resourceMetadataPath: "/.well-known/oauth-protected-resource", resourceUrl: CANONICAL_RESOURCE_ORIGIN,});
export { authHandler as GET, authHandler as POST, authHandler as DELETE };Second, serve the RFC 9728 metadata at the well-known path:
import { protectedResourceHandler, metadataCorsOptionsRequestHandler,} from "mcp-handler";
// protectedResourceHandler takes the FULL resource URL: it becomes the// document's "resource" value, the identifier clients send as the RFC 8707// resource parameter and the one verifyToken compares tokens against.const handler = protectedResourceHandler({ authServerUrls: ["https://your-authorization-server.example.com"], resourceUrl: process.env.MCP_RESOURCE_URL!,});
const corsHandler = metadataCorsOptionsRequestHandler();
export { handler as GET, corsHandler as OPTIONS };What the wrapper actually does, verified against mcp-handler 2.1.1 (the v2 line; withMcpAuth and protectedResourceHandler are unchanged in shape from 1.x, so 1.1.0 deployments read the same):
verifyTokenis the whole trust decision. It receives the request and the parsed bearer token and returns anAuthInfo({ token, clientId, scopes, expiresAt?, resource?, extra? }) orundefined. Returnundefinedand the request is unauthenticated; throw and the caller gets a generic401 invalid_token(the thrown message is not leaked). The library does no token validation of its own: signature, issuer, and audience checks are your job insideverifyToken. Audience validation is the RFC 8707 MUST from section 5 above; skipping it re-opens token replay. The example’sverifyTokenperforms that comparison: every token record names the resource it was minted for, and a record whose normalized resource differs fromMCP_RESOURCE_URLis rejected asundefinedeven when its scopes and expiry are fine (the stubdemo-token-foreignexists to prove it). The expected audience is fixed configuration; the request’sHostandx-forwarded-hostheaders play no part in the comparison. On success the verifiedAuthInforeaches every tool handler asctx.http.authInfo(see Where the principal comes from).requireddefaults tofalse. Unauthenticated requests pass straight through to your tools unless you setrequired: true. The gate’s default must be denial, and this default is not; set it explicitly.requiredScopesis a coarse gate. A token missing any listed scope gets403witherror="insufficient_scope". New in the 2.x line: the challenge now carries the spec’s SHOULD-levelscopehint built fromrequiredScopes, alongsideerror,error_description, andresource_metadata(1.1.0 omitted the hint). Still advertise your scopes in the resource metadata (the lower-levelgenerateProtectedResourceMetadataacceptsadditionalMetadatasuch asscopes_supported;protectedResourceHandlerdoes not). Per-tool scope checks belong inside handlers, keyed offAuthInfo(see Where the principal comes from).- 401/403 semantics come for free. Missing or invalid tokens get
401, insufficient scopes get403, and both carry aWWW-Authenticateheader pointing atresourceMetadataPath(default/.well-known/oauth-protected-resource), which is exactly the discovery hook from section 1.expiresAtis enforced against the current time on every request. resourceUrlpins the canonical URL, and you must set it. Without it, bothwithMcpAuthandprotectedResourceHandlerderive the server’s URL from the request’sx-forwarded-host,x-forwarded-proto, andForwardedheaders, falling back toreq.url(2.1.0 and later expose that derivation as thegetPublicOrigin/getPublicUrlhelpers). Those headers are attacker-influenced unless your proxy strips them, so a request carryingx-forwarded-host: evil.examplewould be told to fetch its discovery document from the attacker’s host, and the metadata document would advertise the attacker’s URL as the resource to bind tokens to. SetresourceUrlfrom fixed configuration (MCP_RESOURCE_URLabove), and note the two shapes:withMcpAuthtakes the origin (scheme, host, port) and appendsresourceMetadataPathitself, whileprotectedResourceHandlertakes the full resource URL of the endpoint.
Two honesty notes. withMcpAuth covers the resource server role only: the authorization server is a separate system (your IdP, or a provider that speaks RFC 8414 metadata), and authServerUrls must list its issuer URLs exactly as they appear in that metadata. And Vercel’s MCP docs still cite older and draft spec revisions in places; where they diverge from the 2026-07-28 spec, the spec is normative, and the wiring above satisfies both.
Security must-knows
- PKCE
S256is mandatory, and the client must confirm AS support via metadata or refuse to proceed. - Validate
isswhen present (RFC 9207): compare against the issuer recorded from validated AS metadata before redeeming the code; simple string comparison, no normalization, and the rule applies to error responses too. - Persisted client credentials are issuer-bound: key them by
issuer, never replay a registration across authorization servers, re-register when the advertised AS changes. - HTTPS everywhere: all AS endpoints over HTTPS; redirect URIs are
localhostor HTTPS only, registered and matched exactly, withstateverified. - Audience-validate every token; reject foreign tokens; no token passthrough. See Authentication.
- Short-lived access tokens, refresh-token rotation for public clients, secure token storage, never log tokens. See Monitoring & audit.
- Client ID Metadata Document caveats: the AS fetches a client-supplied URL (an SSRF risk to guard) and
localhostredirect URIs can be impersonated (display the redirect host, warn the user). required: true, always, unless you have written down why a public tool surface is acceptable. See Deployment posture.- stdio uses no OAuth: it inherits the host process’s trust; credentials come from the environment.
Example implementation
examples/auth-server(in the repository) - the wiring above as a runnable server:withMcpAuthwithrequired: trueandresourceUrlpinned toMCP_RESOURCE_URL, averifyTokenthat checks scopes, expiry, and the RFC 8707 audience against a stub token table, a scope-gatedwhoamitool that denies before it runs, and the RFC 9728 metadata route.tests/auth.test.tsasserts the deny decisions directly (undefinedfor missing, unknown, expired, and foreign-audience tokens), andtests/route-auth.test.tsdrives the realwithMcpAuthwrapper andprotectedResourceHandlerwith FetchRequestobjects: 401invalid_tokenwith the discovery challenge, 403insufficient_scopewith the scope hint, 200 for a valid scoped token,resource_metadataand the metadata document’sresourcestaying canonical under forgedx-forwarded-host,x-forwarded-proto, andForwardedheaders, and a fully scoped token minted for another resource being refused. No live IdP and no network; the trust decision and the HTTP semantics are both exercised offline.
Related
- Security checklist - the operator checkboxes; this page is the flow behind them.
- Where the principal comes from - the verified token’s claims are the principal; never a tool argument.
- Credential brokering - the server’s separate upstream credential, and why passthrough is forbidden.
- Transports - the Streamable HTTP transport this authorization rides on.
- Serverless sessions - why per-request token validation is the only model that survives instance churn.
- Elicitation - url-mode elicitation handles third-party authorization, distinct from this flow.
Bibliography
- Model Context Protocol Specification, Authorization, version 2026-07-28 - https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization
- Model Context Protocol Specification, Client Registration (CIMD,
application_type, issuer binding), version 2026-07-28 - https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration - Model Context Protocol Specification, Authorization Server Discovery, version 2026-07-28 - https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery
- Model Context Protocol Specification, Deprecated Features registry, version 2026-07-28 - https://modelcontextprotocol.io/specification/2026-07-28/deprecated
- Model Context Protocol, Security Best Practices (token passthrough, confused deputy) - https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices
- Vercel Documentation, Deploy MCP servers to Vercel - https://vercel.com/docs/mcp/deploy-mcp-servers-to-vercel
- Vercel, mcp-handler (source and API) - https://github.com/vercel/mcp-handler
- OAuth 2.1 (IETF draft 13) - https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13
- OAuth 2.0 Protected Resource Metadata (RFC 9728) - https://datatracker.ietf.org/doc/html/rfc9728
- Resource Indicators for OAuth 2.0 (RFC 8707) - https://datatracker.ietf.org/doc/html/rfc8707
- OAuth 2.0 Authorization Server Metadata (RFC 8414) - https://datatracker.ietf.org/doc/html/rfc8414
- OAuth 2.0 Authorization Server Issuer Identification (RFC 9207) - https://datatracker.ietf.org/doc/html/rfc9207
- OAuth 2.0 Dynamic Client Registration Protocol (RFC 7591) - https://datatracker.ietf.org/doc/html/rfc7591
- OAuth Client ID Metadata Documents (IETF draft 00) - https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00
- Bearer Token Usage (RFC 6750) - https://datatracker.ietf.org/doc/html/rfc6750