# Authorization flows

Canonical URL: https://vercel-mcp-reference.vercel.app/security/authorization/
Markdown: https://vercel-mcp-reference.vercel.app/security/authorization.md
Audience: security, engineer, architect. MCP spec version: 2026-07-28. Last reviewed: 2026-08-26. Status: stable.

**TL;DR:** MCP authorization is **OAuth 2.1**, and it applies to HTTP transports only: a [stdio](https://vercel-mcp-reference.vercel.app/glossary/#stdio-transport) 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](https://vercel-mcp-reference.vercel.app/glossary/#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](https://vercel-mcp-reference.vercel.app/security/checklist/), for who the token represents see [Where the principal comes from](https://vercel-mcp-reference.vercel.app/security/identity-and-principals/), and for the server's own upstream credentials see [credential brokering](https://vercel-mcp-reference.vercel.app/client-side/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](https://vercel-mcp-reference.vercel.app/client-side/credential-brokering/);
- **url-mode [elicitation](https://vercel-mcp-reference.vercel.app/client-side/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](https://vercel-mcp-reference.vercel.app/glossary/#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

```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-Authenticate`** header on the 401 carrying `resource_metadata` (the metadata URL), which servers **SHOULD** augment with a `scope` hint; 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/mcp` for 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](https://modelcontextprotocol.io/specification/2026-07-28/deprecated) and stays functional for at least a twelve-month window). A client supporting all mechanisms **SHOULD** try, in order:

1. **Pre-registered credentials** it already holds for this authorization server.
2. **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 minimum `client_id`, `client_name`, `redirect_uris`). Advertised by `client_id_metadata_document_supported` in AS metadata; authorization servers and clients **SHOULD** support it.
3. **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.
4. Prompting the user for client details, as the last resort.

Two registration rules are new in 2026-07-28:

- **`application_type` is mandatory in DCR** (SEP-837): a client registering dynamically **MUST** specify an appropriate `application_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 `issuer` identifier, **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 HTTPS `client_id` is 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](https://vercel-mcp-reference.vercel.app/internals/transports/)). The server **MUST** validate the token on every request and return `401` for invalid or expired tokens. There is no longer a [session](https://vercel-mcp-reference.vercel.app/glossary/#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](https://vercel-mcp-reference.vercel.app/internals/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](https://vercel-mcp-reference.vercel.app/client-side/credential-brokering/) and [Trust boundaries](https://vercel-mcp-reference.vercel.app/patterns/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](https://vercel-mcp-reference.vercel.app/client-side/consent-ux/) and [Consent & user approval](https://vercel-mcp-reference.vercel.app/security/checklist/#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:

```ts
// app/api/mcp/route.ts
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:

```ts
// app/.well-known/oauth-protected-resource/route.ts
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):

- **`verifyToken` is the whole trust decision.** It receives the request and the parsed bearer token and returns an `AuthInfo` (`{ token, clientId, scopes, expiresAt?, resource?, extra? }`) or `undefined`. Return `undefined` and the request is unauthenticated; throw and the caller gets a generic `401 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 inside `verifyToken`. Audience validation is the RFC 8707 MUST from section 5 above; skipping it re-opens token replay. The example's `verifyToken` performs that comparison: every token record names the resource it was minted for, and a record whose normalized resource differs from `MCP_RESOURCE_URL` is rejected as `undefined` even when its scopes and expiry are fine (the stub `demo-token-foreign` exists to prove it). The expected audience is fixed configuration; the request's `Host` and `x-forwarded-host` headers play no part in the comparison. On success the verified `AuthInfo` reaches every tool handler as `ctx.http.authInfo` (see [Where the principal comes from](https://vercel-mcp-reference.vercel.app/security/identity-and-principals/)).
- **`required` defaults to `false`.** Unauthenticated requests pass straight through to your tools unless you set `required: true`. The gate's default must be denial, and this default is not; set it explicitly.
- **`requiredScopes` is a coarse gate.** A token missing any listed scope gets `403` with `error="insufficient_scope"`. New in the 2.x line: the challenge now carries the spec's SHOULD-level `scope` hint built from `requiredScopes`, alongside `error`, `error_description`, and `resource_metadata` (1.1.0 omitted the hint). Still advertise your scopes in the resource metadata (the lower-level `generateProtectedResourceMetadata` accepts `additionalMetadata` such as `scopes_supported`; `protectedResourceHandler` does not). Per-tool scope checks belong inside handlers, keyed off `AuthInfo` (see [Where the principal comes from](https://vercel-mcp-reference.vercel.app/security/identity-and-principals/)).
- **401/403 semantics come for free.** Missing or invalid tokens get `401`, insufficient scopes get `403`, and both carry a `WWW-Authenticate` header pointing at `resourceMetadataPath` (default `/.well-known/oauth-protected-resource`), which is exactly the discovery hook from section 1. `expiresAt` is enforced against the current time on every request.
- **`resourceUrl` pins the canonical URL, and you must set it.** Without it, both `withMcpAuth` and `protectedResourceHandler` derive the server's URL from the request's `x-forwarded-host`, `x-forwarded-proto`, and `Forwarded` headers, falling back to `req.url` (2.1.0 and later expose that derivation as the `getPublicOrigin`/`getPublicUrl` helpers). Those headers are attacker-influenced unless your proxy strips them, so a request carrying `x-forwarded-host: evil.example` would 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. Set `resourceUrl` from fixed configuration (`MCP_RESOURCE_URL` above), and note the two shapes: `withMcpAuth` takes the **origin** (scheme, host, port) and appends `resourceMetadataPath` itself, while `protectedResourceHandler` takes 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 `S256` is mandatory**, and the client must confirm AS support via metadata or refuse to proceed.
- **Validate `iss` when 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 `localhost` or HTTPS only, registered and matched exactly, with `state` verified.
- **Audience-validate every token**; reject foreign tokens; **no token passthrough**. See [Authentication](https://vercel-mcp-reference.vercel.app/security/checklist/#authentication).
- **Short-lived access tokens**, refresh-token rotation for public clients, secure token storage, never log tokens. See [Monitoring & audit](https://vercel-mcp-reference.vercel.app/security/checklist/#monitoring--audit).
- **Client ID Metadata Document caveats**: the AS fetches a client-supplied URL (an SSRF risk to guard) and `localhost` redirect 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](https://vercel-mcp-reference.vercel.app/security/checklist/#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: `withMcpAuth` with `required: true` and `resourceUrl` pinned to `MCP_RESOURCE_URL`, a `verifyToken` that checks scopes, expiry, and the RFC 8707 audience against a stub token table, a scope-gated `whoami` tool that denies before it runs, and the RFC 9728 metadata route. `tests/auth.test.ts` asserts the deny decisions directly (`undefined` for missing, unknown, expired, and foreign-audience tokens), and `tests/route-auth.test.ts` drives the real `withMcpAuth` wrapper and `protectedResourceHandler` with Fetch `Request` objects: 401 `invalid_token` with the discovery challenge, 403 `insufficient_scope` with the scope hint, 200 for a valid scoped token, `resource_metadata` and the metadata document's `resource` staying canonical under forged `x-forwarded-host`, `x-forwarded-proto`, and `Forwarded` headers, 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](https://vercel-mcp-reference.vercel.app/security/checklist/) - the operator checkboxes; this page is the flow behind them.
- [Where the principal comes from](https://vercel-mcp-reference.vercel.app/security/identity-and-principals/) - the verified token's claims are the principal; never a tool argument.
- [Credential brokering](https://vercel-mcp-reference.vercel.app/client-side/credential-brokering/) - the server's separate upstream credential, and why passthrough is forbidden.
- [Transports](https://vercel-mcp-reference.vercel.app/internals/transports/) - the Streamable HTTP transport this authorization rides on.
- [Serverless sessions](https://vercel-mcp-reference.vercel.app/internals/serverless-sessions/) - why per-request token validation is the only model that survives instance churn.
- [Elicitation](https://vercel-mcp-reference.vercel.app/client-side/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>
