{
  "type": "guide",
  "id": "add-oauth-to-your-mcp-server",
  "title": "Add OAuth authorization to your own MCP server",
  "summary": "A server-side walkthrough of the MCP 2026-07-28 authorization spec: publish RFC 9728 protected resource metadata, answer 401 and 403 challenges, verify JWT audience per RFC 8707, enforce per-tool scopes, and never pass tokens through. Tested with the MCP TypeScript SDK v2.",
  "author": "Agentica Author",
  "tags": [
    "mcp",
    "oauth",
    "authorization",
    "security",
    "rfc-9728",
    "rfc-8707",
    "resource-server",
    "typescript"
  ],
  "published": "2026-10-21",
  "last_verified": "2026-10-21",
  "difficulty": "intermediate",
  "time_estimate": "45 min",
  "entries": [
    "mcp-authorization",
    "rfc-9728",
    "rfc-8707",
    "rfc-8693",
    "mcp-ext-auth",
    "oauth-client-id-metadata-document",
    "cross-app-access",
    "model-context-protocol",
    "square-mcp",
    "workos-authkit-mcp",
    "auth0-for-ai-agents",
    "descope-agentic-identity",
    "stytch-connected-apps"
  ],
  "links": {
    "html": "https://indexagentica.com/guides/add-oauth-to-your-mcp-server/",
    "markdown": "https://indexagentica.com/guides/add-oauth-to-your-mcp-server.md",
    "json": "https://indexagentica.com/api/longform/guides/add-oauth-to-your-mcp-server.json",
    "source": "https://github.com/Drudley/indexagentica/blob/main/content-long/guides/add-oauth-to-your-mcp-server.md"
  },
  "status": "published",
  "prerequisites": [
    "A remote MCP server on Streamable HTTP, or the willingness to build the small one below",
    "Node.js 20 or later (the code was tested on Node.js 20.19.2)",
    "An authorization server that issues JWT access tokens and publishes RFC 8414 metadata, such as your identity provider"
  ],
  "entries_detail": [
    {
      "id": "mcp-authorization",
      "name": "MCP Authorization (OAuth 2.1 for MCP)",
      "summary": "The MCP specification's transport-level authorization flow for HTTP-based MCP servers, based on a subset of OAuth 2.1 and related RFCs.",
      "url": "https://indexagentica.com/entries/mcp-authorization/",
      "json": "https://indexagentica.com/api/entries/mcp-authorization.json"
    },
    {
      "id": "rfc-9728",
      "name": "RFC 9728: OAuth 2.0 Protected Resource Metadata",
      "summary": "IETF Proposed Standard defining a metadata document a protected resource publishes (at /.well-known/oauth-protected-resource) naming its authorization servers and capabilities.",
      "url": "https://indexagentica.com/entries/rfc-9728/",
      "json": "https://indexagentica.com/api/entries/rfc-9728.json"
    },
    {
      "id": "rfc-8707",
      "name": "RFC 8707: Resource Indicators for OAuth 2.0",
      "summary": "IETF Proposed Standard adding a `resource` parameter so a client can tell the authorization server which protected resource a token is meant for (audience binding).",
      "url": "https://indexagentica.com/entries/rfc-8707/",
      "json": "https://indexagentica.com/api/entries/rfc-8707.json"
    },
    {
      "id": "rfc-8693",
      "name": "RFC 8693: OAuth 2.0 Token Exchange",
      "summary": "IETF Proposed Standard for exchanging one security token for another at the token endpoint, enabling delegation and impersonation flows.",
      "url": "https://indexagentica.com/entries/rfc-8693/",
      "json": "https://indexagentica.com/api/entries/rfc-8693.json"
    },
    {
      "id": "mcp-ext-auth",
      "name": "MCP Authorization Extensions (ext-auth)",
      "summary": "Official repo of optional, additive extensions to MCP authorization, such as Enterprise-Managed Authorization (stable) and OAuth Client Credentials (draft).",
      "url": "https://indexagentica.com/entries/mcp-ext-auth/",
      "json": "https://indexagentica.com/api/entries/mcp-ext-auth.json"
    },
    {
      "id": "oauth-client-id-metadata-document",
      "name": "OAuth Client ID Metadata Document (CIMD)",
      "summary": "IETF OAuth draft letting a client identify itself with a URL as its client_id, pointing to a metadata document, without prior dynamic registration.",
      "url": "https://indexagentica.com/entries/oauth-client-id-metadata-document/",
      "json": "https://indexagentica.com/api/entries/oauth-client-id-metadata-document.json"
    },
    {
      "id": "cross-app-access",
      "name": "Cross App Access (ID-JAG)",
      "summary": "IETF OAuth draft (Identity Assertion JWT Authorization Grant) letting an app use an SSO identity assertion, via the trusted IdP, to get access tokens for a third-party API.",
      "url": "https://indexagentica.com/entries/cross-app-access/",
      "json": "https://indexagentica.com/api/entries/cross-app-access.json"
    },
    {
      "id": "model-context-protocol",
      "name": "Model Context Protocol (MCP)",
      "summary": "Open protocol that standardizes how AI applications connect to external tools, data sources and prompts via MCP servers.",
      "url": "https://indexagentica.com/entries/model-context-protocol/",
      "json": "https://indexagentica.com/api/entries/model-context-protocol.json"
    },
    {
      "id": "square-mcp",
      "name": "Square MCP Server",
      "summary": "Square's hosted MCP server that lets AI assistants work with Square seller data and APIs, authorized via OAuth with Square permission scopes.",
      "url": "https://indexagentica.com/entries/square-mcp/",
      "json": "https://indexagentica.com/api/entries/square-mcp.json"
    },
    {
      "id": "workos-authkit-mcp",
      "name": "WorkOS AuthKit for MCP",
      "summary": "WorkOS AuthKit guide and support for securing MCP servers that require authentication.",
      "url": "https://indexagentica.com/entries/workos-authkit-mcp/",
      "json": "https://indexagentica.com/api/entries/workos-authkit-mcp.json"
    },
    {
      "id": "auth0-for-ai-agents",
      "name": "Auth0 for AI Agents",
      "summary": "Auth0's identity toolkit for AI agents: user authentication, delegated authorization, human-in-the-loop (CIBA) and fine-grained authorization for RAG.",
      "url": "https://indexagentica.com/entries/auth0-for-ai-agents/",
      "json": "https://indexagentica.com/api/entries/auth0-for-ai-agents.json"
    },
    {
      "id": "descope-agentic-identity",
      "name": "Descope Agentic Identity Hub",
      "summary": "Manage authentication, authorization and external credentials for AI agents and MCP servers with Descope.",
      "url": "https://indexagentica.com/entries/descope-agentic-identity/",
      "json": "https://indexagentica.com/api/entries/descope-agentic-identity.json"
    },
    {
      "id": "stytch-connected-apps",
      "name": "Stytch Connected Apps (MCP auth)",
      "summary": "Stytch Connected Apps: authorization for remote MCP servers and connected agent integrations.",
      "url": "https://indexagentica.com/entries/stytch-connected-apps/",
      "json": "https://indexagentica.com/api/entries/stytch-connected-apps.json"
    }
  ],
  "related": [
    {
      "type": "guide",
      "id": "agent-auth-and-delegated-access",
      "title": "Agent auth and delegated access, step by step",
      "url": "https://indexagentica.com/guides/agent-auth-and-delegated-access/",
      "json": "https://indexagentica.com/api/longform/guides/agent-auth-and-delegated-access.json"
    },
    {
      "type": "comparison",
      "id": "agent-identity-and-mcp-auth",
      "title": "Agent identity and MCP authorization platforms",
      "url": "https://indexagentica.com/compare/agent-identity-and-mcp-auth/",
      "json": "https://indexagentica.com/api/longform/compare/agent-identity-and-mcp-auth.json"
    },
    {
      "type": "guide",
      "id": "publish-an-mcp-server",
      "title": "Publish an MCP server so agents can find it",
      "url": "https://indexagentica.com/guides/publish-an-mcp-server/",
      "json": "https://indexagentica.com/api/longform/guides/publish-an-mcp-server.json"
    },
    {
      "type": "guide",
      "id": "connect-an-agent-to-a-remote-mcp-server",
      "title": "Connect an agent to a remote MCP server",
      "url": "https://indexagentica.com/guides/connect-an-agent-to-a-remote-mcp-server/",
      "json": "https://indexagentica.com/api/longform/guides/connect-an-agent-to-a-remote-mcp-server.json"
    },
    {
      "type": "skill",
      "id": "pick-an-mcp-server",
      "title": "Pick an MCP server",
      "url": "https://indexagentica.com/skills/pick-an-mcp-server/",
      "json": "https://indexagentica.com/api/longform/skills/pick-an-mcp-server.json"
    },
    {
      "type": "skill",
      "id": "connect-remote-mcp",
      "title": "Connect a remote MCP server",
      "url": "https://indexagentica.com/skills/connect-remote-mcp/",
      "json": "https://indexagentica.com/api/longform/skills/connect-remote-mcp.json"
    },
    {
      "type": "comparison",
      "id": "llm-red-teaming-tools",
      "title": "Open-source red-teaming tools for LLM apps and agents",
      "url": "https://indexagentica.com/compare/llm-red-teaming-tools/",
      "json": "https://indexagentica.com/api/longform/compare/llm-red-teaming-tools.json"
    },
    {
      "type": "guide",
      "id": "test-your-mcp-server",
      "title": "Test your MCP server with in-process tests and Inspector CI checks",
      "url": "https://indexagentica.com/guides/test-your-mcp-server/",
      "json": "https://indexagentica.com/api/longform/guides/test-your-mcp-server.json"
    },
    {
      "type": "comparison",
      "id": "mcp-gateways",
      "title": "MCP gateways compared",
      "url": "https://indexagentica.com/compare/mcp-gateways/",
      "json": "https://indexagentica.com/api/longform/compare/mcp-gateways.json"
    }
  ],
  "sources": [
    {
      "title": "MCP specification 2026-07-28, Authorization",
      "url": "https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization",
      "accessed": "2026-10-21"
    },
    {
      "title": "MCP specification 2026-07-28, Authorization Server Discovery",
      "url": "https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery",
      "accessed": "2026-10-21"
    },
    {
      "title": "MCP specification 2026-07-28, Authorization Security Considerations",
      "url": "https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations",
      "accessed": "2026-10-21"
    },
    {
      "title": "RFC 9728: OAuth 2.0 Protected Resource Metadata",
      "url": "https://www.rfc-editor.org/rfc/rfc9728.html",
      "accessed": "2026-10-21"
    },
    {
      "title": "RFC 8707: Resource Indicators for OAuth 2.0",
      "url": "https://www.rfc-editor.org/rfc/rfc8707.html",
      "accessed": "2026-10-21"
    },
    {
      "title": "RFC 8693: OAuth 2.0 Token Exchange",
      "url": "https://www.rfc-editor.org/rfc/rfc8693.html",
      "accessed": "2026-10-21"
    },
    {
      "title": "MCP Authorization Extensions (modelcontextprotocol/ext-auth)",
      "url": "https://github.com/modelcontextprotocol/ext-auth",
      "accessed": "2026-10-21"
    },
    {
      "title": "MCP TypeScript SDK v2, Require authorization",
      "url": "https://ts.sdk.modelcontextprotocol.io/v2/serving/authorization.md",
      "accessed": "2026-10-21"
    },
    {
      "title": "MCP TypeScript SDK (modelcontextprotocol/typescript-sdk)",
      "url": "https://github.com/modelcontextprotocol/typescript-sdk",
      "accessed": "2026-10-21"
    },
    {
      "title": "MCP TypeScript SDK bearer-auth example (examples/bearer-auth/server.ts)",
      "url": "https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/bearer-auth/server.ts",
      "accessed": "2026-10-21"
    },
    {
      "title": "jose (panva/jose)",
      "url": "https://github.com/panva/jose",
      "accessed": "2026-10-21"
    },
    {
      "title": "Square MCP protected resource metadata (live response)",
      "url": "https://mcp.squareup.com/.well-known/oauth-protected-resource",
      "accessed": "2026-10-21"
    }
  ],
  "front_matter": {
    "id": "add-oauth-to-your-mcp-server",
    "type": "guide",
    "title": "Add OAuth authorization to your own MCP server",
    "summary": "A server-side walkthrough of the MCP 2026-07-28 authorization spec: publish RFC 9728 protected resource metadata, answer 401 and 403 challenges, verify JWT audience per RFC 8707, enforce per-tool scopes, and never pass tokens through. Tested with the MCP TypeScript SDK v2.",
    "description": "How to turn a remote MCP server into an OAuth 2.1 resource server. It covers what the spec requires of servers, the RFC 9728 metadata document and WWW-Authenticate challenge, audience-bound token validation (RFC 8707), scope challenges and step-up, calling upstream APIs with RFC 8693 token exchange instead of token passthrough, and a complete Express server built on @modelcontextprotocol/server, @modelcontextprotocol/express and jose, tested end to end against a local test authorization server.",
    "author": "Agentica Author",
    "difficulty": "intermediate",
    "time_estimate": "45 min",
    "prerequisites": [
      "A remote MCP server on Streamable HTTP, or the willingness to build the small one below",
      "Node.js 20 or later (the code was tested on Node.js 20.19.2)",
      "An authorization server that issues JWT access tokens and publishes RFC 8414 metadata, such as your identity provider"
    ],
    "tags": [
      "mcp",
      "oauth",
      "authorization",
      "security",
      "rfc-9728",
      "rfc-8707",
      "resource-server",
      "typescript"
    ],
    "entries": [
      "mcp-authorization",
      "rfc-9728",
      "rfc-8707",
      "rfc-8693",
      "mcp-ext-auth",
      "oauth-client-id-metadata-document",
      "cross-app-access",
      "model-context-protocol",
      "square-mcp",
      "workos-authkit-mcp",
      "auth0-for-ai-agents",
      "descope-agentic-identity",
      "stytch-connected-apps"
    ],
    "sources": [
      {
        "title": "MCP specification 2026-07-28, Authorization",
        "url": "https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization",
        "accessed": "2026-10-21"
      },
      {
        "title": "MCP specification 2026-07-28, Authorization Server Discovery",
        "url": "https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery",
        "accessed": "2026-10-21"
      },
      {
        "title": "MCP specification 2026-07-28, Authorization Security Considerations",
        "url": "https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations",
        "accessed": "2026-10-21"
      },
      {
        "title": "RFC 9728: OAuth 2.0 Protected Resource Metadata",
        "url": "https://www.rfc-editor.org/rfc/rfc9728.html",
        "accessed": "2026-10-21"
      },
      {
        "title": "RFC 8707: Resource Indicators for OAuth 2.0",
        "url": "https://www.rfc-editor.org/rfc/rfc8707.html",
        "accessed": "2026-10-21"
      },
      {
        "title": "RFC 8693: OAuth 2.0 Token Exchange",
        "url": "https://www.rfc-editor.org/rfc/rfc8693.html",
        "accessed": "2026-10-21"
      },
      {
        "title": "MCP Authorization Extensions (modelcontextprotocol/ext-auth)",
        "url": "https://github.com/modelcontextprotocol/ext-auth",
        "accessed": "2026-10-21"
      },
      {
        "title": "MCP TypeScript SDK v2, Require authorization",
        "url": "https://ts.sdk.modelcontextprotocol.io/v2/serving/authorization.md",
        "accessed": "2026-10-21"
      },
      {
        "title": "MCP TypeScript SDK (modelcontextprotocol/typescript-sdk)",
        "url": "https://github.com/modelcontextprotocol/typescript-sdk",
        "accessed": "2026-10-21"
      },
      {
        "title": "MCP TypeScript SDK bearer-auth example (examples/bearer-auth/server.ts)",
        "url": "https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/bearer-auth/server.ts",
        "accessed": "2026-10-21"
      },
      {
        "title": "jose (panva/jose)",
        "url": "https://github.com/panva/jose",
        "accessed": "2026-10-21"
      },
      {
        "title": "Square MCP protected resource metadata (live response)",
        "url": "https://mcp.squareup.com/.well-known/oauth-protected-resource",
        "accessed": "2026-10-21"
      }
    ],
    "related": [
      "agent-auth-and-delegated-access",
      "agent-identity-and-mcp-auth",
      "publish-an-mcp-server",
      "connect-an-agent-to-a-remote-mcp-server",
      "pick-an-mcp-server",
      "connect-remote-mcp",
      "llm-red-teaming-tools",
      "test-your-mcp-server",
      "mcp-gateways"
    ],
    "last_verified": "2026-10-21",
    "published": "2026-10-21"
  },
  "markdown": "\nThe [MCP authorization spec](https://indexagentica.com/entries/mcp-authorization/) has two halves. Clients run the OAuth flow; servers have to check what clients bring. Most write-ups cover the client side. This guide covers the server: what a remote MCP server has to publish, check and refuse so that any spec-compliant client can sign in to it, and so that a token meant for somebody else is turned away.\n\nAuthorization is optional in MCP. When you add it, the spec says HTTP-based servers **SHOULD** follow it. STDIO servers **SHOULD NOT** follow it, and should read credentials from the environment instead. So this guide is about servers on Streamable HTTP.\n\nEverything below was checked against the 2026-07-28 revision of the spec. The server code was run end to end against a local test authorization server, and the outputs shown are the real ones.\n\n## What the spec asks of your server\n\nYour MCP server is an OAuth 2.1 **resource server**. It does not issue tokens. An authorization server does that, and that can be your identity provider or a product built for it (see [Agent identity and MCP authorization platforms](https://indexagentica.com/compare/agent-identity-and-mcp-auth/)). The server's own duties are:\n\n| Duty | Spec level | Source |\n|---|---|---|\n| Publish Protected Resource Metadata with at least one `authorization_servers` entry | MUST | MCP spec, [RFC 9728](https://indexagentica.com/entries/rfc-9728/) |\n| Point clients at it, either with `resource_metadata` in the `WWW-Authenticate` header of a 401 or at a well-known URI | MUST (one of the two) | Authorization Server Discovery |\n| Validate access tokens as OAuth 2.1 Section 5.2 describes | MUST | Token Handling |\n| Accept only tokens issued for this server, checked through the audience | MUST | [RFC 8707](https://indexagentica.com/entries/rfc-8707/) Section 2, Security Considerations |\n| Return 401 for missing, invalid or expired tokens | MUST | Token Handling |\n| Return 403 with `error=\"insufficient_scope\"` when scope is missing | SHOULD | Scope Challenge Handling |\n| Include a `scope` parameter in the challenge | SHOULD | Scope Selection Strategy |\n| Never pass the client's token on to upstream APIs | MUST NOT | Security Considerations |\n| Leave `offline_access` out of the challenge and `scopes_supported` | SHOULD NOT include it | Refresh Tokens |\n\nThe client side, covering PKCE, `resource` in both requests, issuer checks and client registration through [Client ID Metadata Documents](https://indexagentica.com/entries/oauth-client-id-metadata-document/), is covered in [Agent auth and delegated access, step by step](https://indexagentica.com/guides/agent-auth-and-delegated-access/).\n\n```mermaid\nsequenceDiagram\n  participant C as MCP client\n  participant M as Your MCP server (resource server)\n  participant A as Authorization server\n  C->>M: POST /mcp (no token)\n  M-->>C: 401, WWW-Authenticate: Bearer resource_metadata=\"...\", scope=\"notes:read\"\n  C->>M: GET /.well-known/oauth-protected-resource/mcp\n  M-->>C: {\"resource\": \".../mcp\", \"authorization_servers\": [\"https://auth...\"]}\n  C->>A: discovery, then authorization code + PKCE with resource=.../mcp\n  A-->>C: access token (aud = .../mcp)\n  C->>M: POST /mcp, Authorization: Bearer ...\n  M->>M: verify signature, issuer, expiry, audience, scope\n  M-->>C: MCP response\n```\n\n## Step 1: pick the canonical URI\n\nEverything hangs on one string: your server's **canonical URI**. It is the `resource` value in your metadata, the `resource` parameter clients send, and the audience your tokens must carry. The spec gives `https://mcp.example.com/mcp` and `https://mcp.example.com` as valid examples. It rules out a missing scheme or a fragment, and it says to drop the trailing slash unless the slash means something. Pick one and use it everywhere.\n\n## Step 2: publish protected resource metadata (RFC 9728)\n\nThe metadata document is JSON at a well-known path. RFC 9728 puts `/.well-known/oauth-protected-resource` between the host and any path, so an endpoint at `https://mcp.example.com/mcp` publishes at `https://mcp.example.com/.well-known/oauth-protected-resource/mcp`. The fields that matter for MCP:\n\n| Field | RFC 9728 | Notes |\n|---|---|---|\n| `resource` | REQUIRED | Your canonical URI. Clients MUST NOT use the document if it is not identical to the URL they used (RFC 9728 Section 3.3) |\n| `authorization_servers` | OPTIONAL in RFC 9728, but the MCP spec requires at least one | Issuer identifiers of the authorization servers clients can use |\n| `scopes_supported` | RECOMMENDED | The MCP spec treats it as the minimal set for basic use. Ask for more through step-up |\n| `resource_name` | RECOMMENDED | Human-readable name |\n| `bearer_methods_supported` | OPTIONAL | MCP only allows the `Authorization` header. Tokens MUST NOT go in the query string |\n\nA real one is Square's ([Square MCP](https://indexagentica.com/entries/square-mcp/)), fetched on 2026-10-21. It has `\"resource\": \"https://mcp.squareup.com/mcp\"`, `\"authorization_servers\": [\"https://mcp.squareup.com\"]` and a long `scopes_supported` list such as `PAYMENTS_READ` and `ORDERS_WRITE`.\n\n## Step 3: challenge, verify, scope\n\nThree responses do the work:\n\n- **No token, or a bad one:** `401` with `WWW-Authenticate: Bearer resource_metadata=\"<metadata URL>\"`, plus `scope=\"...\"` for what this request needs. This header is what starts the client's OAuth flow.\n- **Valid token, wrong audience:** also `401` (`invalid_token`). A token for another server is not a valid token here.\n- **Valid token, too little scope:** `403` with `error=\"insufficient_scope\"`, the `scope` the operation needs and `resource_metadata` again. The spec asks you to name *all* scopes the operation needs in one challenge, not one at a time. It also says you must account for scope hierarchies when a broader scope implies a narrower one.\n\n## Step 4: build it with the TypeScript SDK\n\nThe MCP TypeScript SDK v2 (`@modelcontextprotocol/server`, Apache-2.0) has the resource-server half built in. The SDK's own docs say the old v1 authorization-server helpers are frozen in `@modelcontextprotocol/server-legacy/auth`, and recommend a dedicated identity provider for new servers.\n\n```bash\nnpm install @modelcontextprotocol/server @modelcontextprotocol/express @modelcontextprotocol/node express jose zod\n```\n\nTested with `@modelcontextprotocol/server` 2.3.1, `@modelcontextprotocol/express` 2.0.2, `@modelcontextprotocol/node` 2.1.1, `express` 5.3.0, `jose` 6.2.13 and `zod` 4.6.7. Upgrade `@modelcontextprotocol/express` together with `@modelcontextprotocol/server`. The SDK docs warn that `@modelcontextprotocol/express` 2.0.1 does not pass `expectedResource` on, so no audience check happens.\n\n```js\n// server.mjs: an MCP server that is an OAuth 2.1 resource server (MCP authorization spec 2026-07-28).\nimport { createRemoteJWKSet, jwtVerify } from \"jose\";\nimport {\n  createMcpExpressApp,\n  getOAuthProtectedResourceMetadataUrl,\n  mcpAuthMetadataRouter,\n  requireBearerAuth,\n} from \"@modelcontextprotocol/express\";\nimport { toNodeHandler } from \"@modelcontextprotocol/node\";\nimport { createMcpHandler, McpServer, OAuthError, OAuthErrorCode, requireScopes } from \"@modelcontextprotocol/server\";\nimport * as z from \"zod/v4\";\n\nconst PORT = Number(process.env.PORT ?? 3000);\nconst mcpServerUrl = new URL(process.env.MCP_URL ?? `http://localhost:${PORT}/mcp`); // canonical URI = token audience\nconst issuer = process.env.AS_ISSUER;                                                // e.g. https://auth.example.com\n\n// 1. Your authorization server's RFC 8414 metadata (fetched once at startup).\nconst oauthMetadata = await (await fetch(new URL(\"/.well-known/oauth-authorization-server\", issuer))).json();\nif (oauthMetadata.issuer !== issuer) throw new Error(\"AS metadata issuer mismatch\");\nconst jwks = createRemoteJWKSet(new URL(oauthMetadata.jwks_uri));\n\n// 2. Verify a JWT access token: signature, issuer, expiry and audience (RFC 8707).\nasync function verifyAccessToken(token) {\n  try {\n    const { payload } = await jwtVerify(token, jwks, { issuer, audience: mcpServerUrl.href });\n    return {\n      token,\n      clientId: payload.client_id ?? payload.sub,\n      scopes: typeof payload.scope === \"string\" ? payload.scope.split(\" \") : [],\n      expiresAt: payload.exp,\n      resource: mcpServerUrl, // jwtVerify already checked aud; report it so expectedResource matches\n    };\n  } catch (e) {\n    throw new OAuthError(OAuthErrorCode.InvalidToken, e.code ?? \"invalid token\");\n  }\n}\n\n// 3. The MCP server, with a per-tool scope on the write tool.\nconst notes = [];\nfunction buildServer() {\n  const server = new McpServer({ name: \"notes\", version: \"1.0.0\" });\n  server.registerTool(\"list-notes\", { description: \"List notes\", inputSchema: z.object({}) }, async () => ({\n    content: [{ type: \"text\", text: JSON.stringify(notes) }],\n  }));\n  server.registerTool(\n    \"add-note\",\n    { description: \"Add a note\", inputSchema: z.object({ text: z.string() }), scopeChallenge: requireScopes(\"notes:write\") },\n    async ({ text }) => { notes.push(text); return { content: [{ type: \"text\", text: \"ok\" }] }; },\n  );\n  return server;\n}\n\n// 4. Wire it up: RFC 9728 metadata + bearer gate in front of /mcp.\nconst app = createMcpExpressApp({ host: \"0.0.0.0\", allowedHosts: [mcpServerUrl.hostname] });\napp.use(mcpAuthMetadataRouter({\n  oauthMetadata,\n  resourceServerUrl: mcpServerUrl,\n  scopesSupported: [\"notes:read\", \"notes:write\"],\n  resourceName: \"Notes MCP\",\n}));\nconst auth = requireBearerAuth({\n  verifier: { verifyAccessToken },\n  requiredScopes: [\"notes:read\"],\n  resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),\n  expectedResource: mcpServerUrl,\n});\nconst node = toNodeHandler(createMcpHandler(buildServer));\napp.all(\"/mcp\", auth, (req, res) => void node(req, res, req.body));\napp.listen(PORT, () => console.error(`MCP server on ${mcpServerUrl}`));\n```\n\nWhat each piece does:\n\n- `mcpAuthMetadataRouter` serves the RFC 9728 document at the path-aware location (`/.well-known/oauth-protected-resource/mcp`). It also mirrors your authorization server's metadata at `/.well-known/oauth-authorization-server` for clients that probe your origin.\n- `requireBearerAuth` answers `401 invalid_token` for missing, malformed or expired tokens and `403 insufficient_scope` when `requiredScopes` is not met. Both carry the `resource_metadata` challenge. It also rejects a token whose `expiresAt` is unset, so always fill it in.\n- `jwtVerify` with `issuer` and `audience` is the audience check the spec requires. `expectedResource` is the SDK's own second check on the `resource` your verifier reports.\n- `scopeChallenge: requireScopes(\"notes:write\")` gives one tool a stricter scope. The SDK runs it before the tool, and before input validation. Scope hierarchies are your job: the SDK docs say it \"does not infer hierarchies\".\n- If your tokens are opaque rather than JWTs, replace `jwtVerify` with an RFC 7662 introspection call. Check `active`, `aud` and `exp` from the response, the same way.\n\nOn Cloudflare Workers, Deno, Bun or Hono, the same gate is `requireBearerAuth` from `@modelcontextprotocol/server`. It works on web-standard `Request` and `Response`, and `oauthMetadataResponse` serves the metadata.\n\n## Step 5: test it without a real identity provider\n\nTo test, you need an authorization server that publishes metadata and a JWKS and can mint tokens. This short stand-in (test only, never deploy it) does exactly that with `jose`:\n\n```js\n// fake-as.mjs: test-only stand-in for an authorization server (RFC 8414 metadata + JWKS + a /mint endpoint).\nimport http from \"node:http\";\nimport { generateKeyPair, exportJWK, SignJWT } from \"jose\";\nconst { publicKey, privateKey } = await generateKeyPair(\"RS256\");\nconst jwk = { ...(await exportJWK(publicKey)), kid: \"k1\", alg: \"RS256\", use: \"sig\" };\nconst issuer = \"http://localhost:9000\";\nhttp.createServer(async (req, res) => {\n  const u = new URL(req.url, issuer);\n  const json = (o) => { res.setHeader(\"content-type\", \"application/json\"); res.end(JSON.stringify(o)); };\n  if (u.pathname === \"/.well-known/oauth-authorization-server\") return json({\n    issuer, authorization_endpoint: issuer + \"/authorize\", token_endpoint: issuer + \"/token\",\n    jwks_uri: issuer + \"/jwks.json\", response_types_supported: [\"code\"], code_challenge_methods_supported: [\"S256\"] });\n  if (u.pathname === \"/jwks.json\") return json({ keys: [jwk] });\n  if (u.pathname === \"/mint\") {  // ?aud=...&scope=...\n    const t = await new SignJWT({ scope: u.searchParams.get(\"scope\"), client_id: \"test-client\" })\n      .setProtectedHeader({ alg: \"RS256\", kid: \"k1\" }).setIssuer(issuer).setSubject(\"user-1\")\n      .setAudience(u.searchParams.get(\"aud\")).setIssuedAt().setExpirationTime(\"5m\").sign(privateKey);\n    res.end(t); return;\n  }\n  res.statusCode = 404; res.end();\n}).listen(9000);\n```\n\n```bash\nnode fake-as.mjs &\nAS_ISSUER=http://localhost:9000 node server.mjs &\nM=http://localhost:3000/mcp\nH=(-H 'content-type: application/json' -H 'accept: application/json, text/event-stream')\nINIT='{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{},\"clientInfo\":{\"name\":\"t\",\"version\":\"1\"}}}'\n\ncurl -si -X POST $M \"${H[@]}\" -d \"$INIT\"                                   # 1. no token\ncurl -s http://localhost:3000/.well-known/oauth-protected-resource/mcp      # 2. metadata\nT=$(curl -s \"http://localhost:9000/mint?aud=https://other.example.com&scope=notes:read\")\ncurl -si -X POST $M \"${H[@]}\" -H \"authorization: Bearer $T\" -d \"$INIT\"     # 3. wrong audience\n```\n\nThe real responses, from a run on 2026-10-21:\n\n```http\n# 1. no token\nHTTP/1.1 401 Unauthorized\nWWW-Authenticate: Bearer error=\"invalid_token\", error_description=\"Missing Authorization header\", scope=\"notes:read\", resource_metadata=\"http://localhost:3000/.well-known/oauth-protected-resource/mcp\"\n\n# 2. metadata\n{\"resource\":\"http://localhost:3000/mcp\",\"authorization_servers\":[\"http://localhost:9000\"],\"scopes_supported\":[\"notes:read\",\"notes:write\"],\"resource_name\":\"Notes MCP\"}\n\n# 3. token minted for https://other.example.com\nHTTP/1.1 401 Unauthorized\nWWW-Authenticate: Bearer error=\"invalid_token\", error_description=\"ERR_JWT_CLAIM_VALIDATION_FAILED\", scope=\"notes:read\", resource_metadata=\"http://localhost:3000/.well-known/oauth-protected-resource/mcp\"\n\n# 4. right audience, scope \"profile\" only\nHTTP/1.1 403 Forbidden\nWWW-Authenticate: Bearer error=\"insufficient_scope\", error_description=\"Insufficient scope\", scope=\"notes:read\", resource_metadata=\"...\"\n\n# 5. notes:read token: initialize and list-notes return 200\n\n# 6. notes:read token calling add-note\nHTTP/1.1 403 Forbidden\nwww-authenticate: Bearer error=\"insufficient_scope\", error_description=\"Insufficient scope\", scope=\"notes:write\", resource_metadata=\"...\"\n\n# 7. token with \"notes:read notes:write\": add-note returns 200 {\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"ok\"}]}}\n```\n\nFor a full client-side check, point the MCP Inspector or a client from [Connect an agent to a remote MCP server](https://indexagentica.com/guides/connect-an-agent-to-a-remote-mcp-server/) at it once a real authorization server is behind it. The test server above has no `/authorize` or `/token`, so it cannot complete a browser flow.\n\n## Step 6: calling upstream APIs without token passthrough\n\nMany MCP servers wrap another API. The spec is blunt here. The token the client sent you is for *you*. You **MUST NOT** pass it through, and the upstream API needs \"a separate token, issued by the upstream authorization server\". You have two options:\n\n1. **Your server is an OAuth client of the upstream API.** Store the user's upstream tokens server-side, for example in a vault, keyed to the MCP user. [Agent auth and delegated access](https://indexagentica.com/guides/agent-auth-and-delegated-access/) shows this pattern.\n2. **Token exchange ([RFC 8693](https://indexagentica.com/entries/rfc-8693/)), when the same authorization server, or a federated one, protects both.** Your server trades the inbound token for a new one aimed at the upstream resource. The RFC's own example request:\n\n```http\nPOST /as/token.oauth2 HTTP/1.1\nHost: as.example.com\nAuthorization: Basic cnMwODpsb25nLXNlY3VyZS1yYW5kb20tc2VjcmV0\nContent-Type: application/x-www-form-urlencoded\n\ngrant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange\n&resource=https%3A%2F%2Fbackend.example.com%2Fapi\n&subject_token=accVkjcJyb4BWCxGsndESCJQbdFMogUC5PbRDqceLTC\n&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token\n```\n\nThe `resource` (or `audience`) parameter names the upstream service, so the new token is bound to it. Whether your authorization server supports token exchange, and for which clients, depends on the product. Check before you design around it. [Cross App Access](https://indexagentica.com/entries/cross-app-access/) (ID-JAG), for enterprise SSO, is built on token exchange.\n\n## Beyond the core: authorization extensions\n\nThe MCP project keeps optional, composable extensions in [MCP Authorization Extensions](https://indexagentica.com/entries/mcp-ext-auth/). On 2026-10-21 the repo listed **Enterprise-Managed Authorization** as stable and **Client Credentials** (machine-to-machine, no user present) as a draft. Neither changes what your server must do as a resource server. They change how clients get tokens.\n\n## Test it like an attacker\n\nOnce the auth works, try to break the tools behind it: prompt injection that makes an authorized user's agent misuse a tool, or object-level authorization gaps between users. [Open-source red-teaming tools for LLM apps and agents](https://indexagentica.com/compare/llm-red-teaming-tools/) compares tools that automate this, including MCP-specific tests.\n\n## Checklist\n\n- [ ] One canonical URI, used as `resource`, in the metadata and as the token audience.\n- [ ] RFC 9728 document served at the path-aware well-known URL, with `resource` exactly equal to the endpoint URL and at least one `authorization_servers` entry.\n- [ ] `401` with `resource_metadata` and `scope` for missing, invalid, expired or wrong-audience tokens.\n- [ ] Signature, issuer, `exp` and `aud` checked on every request (or introspection with the same checks).\n- [ ] `403 insufficient_scope` naming every scope the operation needs, with per-tool scopes where writes are riskier than reads.\n- [ ] No `offline_access` in `scopes_supported` or challenges.\n- [ ] Tokens never logged, never in query strings, never forwarded upstream.\n- [ ] HTTPS everywhere outside local testing. The spec requires HTTPS for all authorization server endpoints.\n- [ ] Host header validation on (`allowedHosts` in `createMcpExpressApp`).\n\n## Troubleshooting\n\n- **Client never starts the OAuth flow:** the 401 is missing `WWW-Authenticate` or `resource_metadata`, or a proxy strips the header. Check with `curl -i`.\n- **Client rejects your metadata:** `resource` differs from the URL the client used, often because of a trailing slash or `http` versus `https` behind a proxy. Set `MCP_URL` to the public URL.\n- **Every token gets `invalid_token`:** the audience in tokens does not equal your canonical URI. Configure the authorization server to put the `resource` value into `aud`, or set its default resource. Also check that `exp` is present.\n- **Audience check silently not happening (Express):** `@modelcontextprotocol/express` is older than your `@modelcontextprotocol/server`. Upgrade both together.\n",
  "raw": "---\nid: add-oauth-to-your-mcp-server\ntype: guide\ntitle: Add OAuth authorization to your own MCP server\nsummary: \"A server-side walkthrough of the MCP 2026-07-28 authorization spec: publish RFC 9728 protected resource metadata, answer 401 and 403 challenges, verify JWT audience per RFC 8707, enforce per-tool scopes, and never pass tokens through. Tested with the MCP TypeScript SDK v2.\"\ndescription: \"How to turn a remote MCP server into an OAuth 2.1 resource server. It covers what the spec requires of servers, the RFC 9728 metadata document and WWW-Authenticate challenge, audience-bound token validation (RFC 8707), scope challenges and step-up, calling upstream APIs with RFC 8693 token exchange instead of token passthrough, and a complete Express server built on @modelcontextprotocol/server, @modelcontextprotocol/express and jose, tested end to end against a local test authorization server.\"\nauthor: Agentica Author\ndifficulty: intermediate\ntime_estimate: 45 min\nprerequisites:\n  - A remote MCP server on Streamable HTTP, or the willingness to build the small one below\n  - Node.js 20 or later (the code was tested on Node.js 20.19.2)\n  - An authorization server that issues JWT access tokens and publishes RFC 8414 metadata, such as your identity provider\ntags: [mcp, oauth, authorization, security, rfc-9728, rfc-8707, resource-server, typescript]\nentries: [mcp-authorization, rfc-9728, rfc-8707, rfc-8693, mcp-ext-auth, oauth-client-id-metadata-document, cross-app-access, model-context-protocol, square-mcp, workos-authkit-mcp, auth0-for-ai-agents, descope-agentic-identity, stytch-connected-apps]\nsources:\n  - title: MCP specification 2026-07-28, Authorization\n    url: https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization\n    accessed: 2026-10-21\n  - title: MCP specification 2026-07-28, Authorization Server Discovery\n    url: https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery\n    accessed: 2026-10-21\n  - title: MCP specification 2026-07-28, Authorization Security Considerations\n    url: https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations\n    accessed: 2026-10-21\n  - title: \"RFC 9728: OAuth 2.0 Protected Resource Metadata\"\n    url: https://www.rfc-editor.org/rfc/rfc9728.html\n    accessed: 2026-10-21\n  - title: \"RFC 8707: Resource Indicators for OAuth 2.0\"\n    url: https://www.rfc-editor.org/rfc/rfc8707.html\n    accessed: 2026-10-21\n  - title: \"RFC 8693: OAuth 2.0 Token Exchange\"\n    url: https://www.rfc-editor.org/rfc/rfc8693.html\n    accessed: 2026-10-21\n  - title: MCP Authorization Extensions (modelcontextprotocol/ext-auth)\n    url: https://github.com/modelcontextprotocol/ext-auth\n    accessed: 2026-10-21\n  - title: MCP TypeScript SDK v2, Require authorization\n    url: https://ts.sdk.modelcontextprotocol.io/v2/serving/authorization.md\n    accessed: 2026-10-21\n  - title: MCP TypeScript SDK (modelcontextprotocol/typescript-sdk)\n    url: https://github.com/modelcontextprotocol/typescript-sdk\n    accessed: 2026-10-21\n  - title: MCP TypeScript SDK bearer-auth example (examples/bearer-auth/server.ts)\n    url: https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/bearer-auth/server.ts\n    accessed: 2026-10-21\n  - title: jose (panva/jose)\n    url: https://github.com/panva/jose\n    accessed: 2026-10-21\n  - title: Square MCP protected resource metadata (live response)\n    url: https://mcp.squareup.com/.well-known/oauth-protected-resource\n    accessed: 2026-10-21\nrelated: [agent-auth-and-delegated-access, agent-identity-and-mcp-auth, publish-an-mcp-server, connect-an-agent-to-a-remote-mcp-server, pick-an-mcp-server, connect-remote-mcp, llm-red-teaming-tools, test-your-mcp-server, mcp-gateways]\nlast_verified: 2026-10-21\npublished: 2026-10-21\n---\n\nThe [MCP authorization spec](https://indexagentica.com/entries/mcp-authorization/) has two halves. Clients run the OAuth flow; servers have to check what clients bring. Most write-ups cover the client side. This guide covers the server: what a remote MCP server has to publish, check and refuse so that any spec-compliant client can sign in to it, and so that a token meant for somebody else is turned away.\n\nAuthorization is optional in MCP. When you add it, the spec says HTTP-based servers **SHOULD** follow it. STDIO servers **SHOULD NOT** follow it, and should read credentials from the environment instead. So this guide is about servers on Streamable HTTP.\n\nEverything below was checked against the 2026-07-28 revision of the spec. The server code was run end to end against a local test authorization server, and the outputs shown are the real ones.\n\n## What the spec asks of your server\n\nYour MCP server is an OAuth 2.1 **resource server**. It does not issue tokens. An authorization server does that, and that can be your identity provider or a product built for it (see [Agent identity and MCP authorization platforms](https://indexagentica.com/compare/agent-identity-and-mcp-auth/)). The server's own duties are:\n\n| Duty | Spec level | Source |\n|---|---|---|\n| Publish Protected Resource Metadata with at least one `authorization_servers` entry | MUST | MCP spec, [RFC 9728](https://indexagentica.com/entries/rfc-9728/) |\n| Point clients at it, either with `resource_metadata` in the `WWW-Authenticate` header of a 401 or at a well-known URI | MUST (one of the two) | Authorization Server Discovery |\n| Validate access tokens as OAuth 2.1 Section 5.2 describes | MUST | Token Handling |\n| Accept only tokens issued for this server, checked through the audience | MUST | [RFC 8707](https://indexagentica.com/entries/rfc-8707/) Section 2, Security Considerations |\n| Return 401 for missing, invalid or expired tokens | MUST | Token Handling |\n| Return 403 with `error=\"insufficient_scope\"` when scope is missing | SHOULD | Scope Challenge Handling |\n| Include a `scope` parameter in the challenge | SHOULD | Scope Selection Strategy |\n| Never pass the client's token on to upstream APIs | MUST NOT | Security Considerations |\n| Leave `offline_access` out of the challenge and `scopes_supported` | SHOULD NOT include it | Refresh Tokens |\n\nThe client side, covering PKCE, `resource` in both requests, issuer checks and client registration through [Client ID Metadata Documents](https://indexagentica.com/entries/oauth-client-id-metadata-document/), is covered in [Agent auth and delegated access, step by step](https://indexagentica.com/guides/agent-auth-and-delegated-access/).\n\n```mermaid\nsequenceDiagram\n  participant C as MCP client\n  participant M as Your MCP server (resource server)\n  participant A as Authorization server\n  C->>M: POST /mcp (no token)\n  M-->>C: 401, WWW-Authenticate: Bearer resource_metadata=\"...\", scope=\"notes:read\"\n  C->>M: GET /.well-known/oauth-protected-resource/mcp\n  M-->>C: {\"resource\": \".../mcp\", \"authorization_servers\": [\"https://auth...\"]}\n  C->>A: discovery, then authorization code + PKCE with resource=.../mcp\n  A-->>C: access token (aud = .../mcp)\n  C->>M: POST /mcp, Authorization: Bearer ...\n  M->>M: verify signature, issuer, expiry, audience, scope\n  M-->>C: MCP response\n```\n\n## Step 1: pick the canonical URI\n\nEverything hangs on one string: your server's **canonical URI**. It is the `resource` value in your metadata, the `resource` parameter clients send, and the audience your tokens must carry. The spec gives `https://mcp.example.com/mcp` and `https://mcp.example.com` as valid examples. It rules out a missing scheme or a fragment, and it says to drop the trailing slash unless the slash means something. Pick one and use it everywhere.\n\n## Step 2: publish protected resource metadata (RFC 9728)\n\nThe metadata document is JSON at a well-known path. RFC 9728 puts `/.well-known/oauth-protected-resource` between the host and any path, so an endpoint at `https://mcp.example.com/mcp` publishes at `https://mcp.example.com/.well-known/oauth-protected-resource/mcp`. The fields that matter for MCP:\n\n| Field | RFC 9728 | Notes |\n|---|---|---|\n| `resource` | REQUIRED | Your canonical URI. Clients MUST NOT use the document if it is not identical to the URL they used (RFC 9728 Section 3.3) |\n| `authorization_servers` | OPTIONAL in RFC 9728, but the MCP spec requires at least one | Issuer identifiers of the authorization servers clients can use |\n| `scopes_supported` | RECOMMENDED | The MCP spec treats it as the minimal set for basic use. Ask for more through step-up |\n| `resource_name` | RECOMMENDED | Human-readable name |\n| `bearer_methods_supported` | OPTIONAL | MCP only allows the `Authorization` header. Tokens MUST NOT go in the query string |\n\nA real one is Square's ([Square MCP](https://indexagentica.com/entries/square-mcp/)), fetched on 2026-10-21. It has `\"resource\": \"https://mcp.squareup.com/mcp\"`, `\"authorization_servers\": [\"https://mcp.squareup.com\"]` and a long `scopes_supported` list such as `PAYMENTS_READ` and `ORDERS_WRITE`.\n\n## Step 3: challenge, verify, scope\n\nThree responses do the work:\n\n- **No token, or a bad one:** `401` with `WWW-Authenticate: Bearer resource_metadata=\"<metadata URL>\"`, plus `scope=\"...\"` for what this request needs. This header is what starts the client's OAuth flow.\n- **Valid token, wrong audience:** also `401` (`invalid_token`). A token for another server is not a valid token here.\n- **Valid token, too little scope:** `403` with `error=\"insufficient_scope\"`, the `scope` the operation needs and `resource_metadata` again. The spec asks you to name *all* scopes the operation needs in one challenge, not one at a time. It also says you must account for scope hierarchies when a broader scope implies a narrower one.\n\n## Step 4: build it with the TypeScript SDK\n\nThe MCP TypeScript SDK v2 (`@modelcontextprotocol/server`, Apache-2.0) has the resource-server half built in. The SDK's own docs say the old v1 authorization-server helpers are frozen in `@modelcontextprotocol/server-legacy/auth`, and recommend a dedicated identity provider for new servers.\n\n```bash\nnpm install @modelcontextprotocol/server @modelcontextprotocol/express @modelcontextprotocol/node express jose zod\n```\n\nTested with `@modelcontextprotocol/server` 2.3.1, `@modelcontextprotocol/express` 2.0.2, `@modelcontextprotocol/node` 2.1.1, `express` 5.3.0, `jose` 6.2.13 and `zod` 4.6.7. Upgrade `@modelcontextprotocol/express` together with `@modelcontextprotocol/server`. The SDK docs warn that `@modelcontextprotocol/express` 2.0.1 does not pass `expectedResource` on, so no audience check happens.\n\n```js\n// server.mjs: an MCP server that is an OAuth 2.1 resource server (MCP authorization spec 2026-07-28).\nimport { createRemoteJWKSet, jwtVerify } from \"jose\";\nimport {\n  createMcpExpressApp,\n  getOAuthProtectedResourceMetadataUrl,\n  mcpAuthMetadataRouter,\n  requireBearerAuth,\n} from \"@modelcontextprotocol/express\";\nimport { toNodeHandler } from \"@modelcontextprotocol/node\";\nimport { createMcpHandler, McpServer, OAuthError, OAuthErrorCode, requireScopes } from \"@modelcontextprotocol/server\";\nimport * as z from \"zod/v4\";\n\nconst PORT = Number(process.env.PORT ?? 3000);\nconst mcpServerUrl = new URL(process.env.MCP_URL ?? `http://localhost:${PORT}/mcp`); // canonical URI = token audience\nconst issuer = process.env.AS_ISSUER;                                                // e.g. https://auth.example.com\n\n// 1. Your authorization server's RFC 8414 metadata (fetched once at startup).\nconst oauthMetadata = await (await fetch(new URL(\"/.well-known/oauth-authorization-server\", issuer))).json();\nif (oauthMetadata.issuer !== issuer) throw new Error(\"AS metadata issuer mismatch\");\nconst jwks = createRemoteJWKSet(new URL(oauthMetadata.jwks_uri));\n\n// 2. Verify a JWT access token: signature, issuer, expiry and audience (RFC 8707).\nasync function verifyAccessToken(token) {\n  try {\n    const { payload } = await jwtVerify(token, jwks, { issuer, audience: mcpServerUrl.href });\n    return {\n      token,\n      clientId: payload.client_id ?? payload.sub,\n      scopes: typeof payload.scope === \"string\" ? payload.scope.split(\" \") : [],\n      expiresAt: payload.exp,\n      resource: mcpServerUrl, // jwtVerify already checked aud; report it so expectedResource matches\n    };\n  } catch (e) {\n    throw new OAuthError(OAuthErrorCode.InvalidToken, e.code ?? \"invalid token\");\n  }\n}\n\n// 3. The MCP server, with a per-tool scope on the write tool.\nconst notes = [];\nfunction buildServer() {\n  const server = new McpServer({ name: \"notes\", version: \"1.0.0\" });\n  server.registerTool(\"list-notes\", { description: \"List notes\", inputSchema: z.object({}) }, async () => ({\n    content: [{ type: \"text\", text: JSON.stringify(notes) }],\n  }));\n  server.registerTool(\n    \"add-note\",\n    { description: \"Add a note\", inputSchema: z.object({ text: z.string() }), scopeChallenge: requireScopes(\"notes:write\") },\n    async ({ text }) => { notes.push(text); return { content: [{ type: \"text\", text: \"ok\" }] }; },\n  );\n  return server;\n}\n\n// 4. Wire it up: RFC 9728 metadata + bearer gate in front of /mcp.\nconst app = createMcpExpressApp({ host: \"0.0.0.0\", allowedHosts: [mcpServerUrl.hostname] });\napp.use(mcpAuthMetadataRouter({\n  oauthMetadata,\n  resourceServerUrl: mcpServerUrl,\n  scopesSupported: [\"notes:read\", \"notes:write\"],\n  resourceName: \"Notes MCP\",\n}));\nconst auth = requireBearerAuth({\n  verifier: { verifyAccessToken },\n  requiredScopes: [\"notes:read\"],\n  resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),\n  expectedResource: mcpServerUrl,\n});\nconst node = toNodeHandler(createMcpHandler(buildServer));\napp.all(\"/mcp\", auth, (req, res) => void node(req, res, req.body));\napp.listen(PORT, () => console.error(`MCP server on ${mcpServerUrl}`));\n```\n\nWhat each piece does:\n\n- `mcpAuthMetadataRouter` serves the RFC 9728 document at the path-aware location (`/.well-known/oauth-protected-resource/mcp`). It also mirrors your authorization server's metadata at `/.well-known/oauth-authorization-server` for clients that probe your origin.\n- `requireBearerAuth` answers `401 invalid_token` for missing, malformed or expired tokens and `403 insufficient_scope` when `requiredScopes` is not met. Both carry the `resource_metadata` challenge. It also rejects a token whose `expiresAt` is unset, so always fill it in.\n- `jwtVerify` with `issuer` and `audience` is the audience check the spec requires. `expectedResource` is the SDK's own second check on the `resource` your verifier reports.\n- `scopeChallenge: requireScopes(\"notes:write\")` gives one tool a stricter scope. The SDK runs it before the tool, and before input validation. Scope hierarchies are your job: the SDK docs say it \"does not infer hierarchies\".\n- If your tokens are opaque rather than JWTs, replace `jwtVerify` with an RFC 7662 introspection call. Check `active`, `aud` and `exp` from the response, the same way.\n\nOn Cloudflare Workers, Deno, Bun or Hono, the same gate is `requireBearerAuth` from `@modelcontextprotocol/server`. It works on web-standard `Request` and `Response`, and `oauthMetadataResponse` serves the metadata.\n\n## Step 5: test it without a real identity provider\n\nTo test, you need an authorization server that publishes metadata and a JWKS and can mint tokens. This short stand-in (test only, never deploy it) does exactly that with `jose`:\n\n```js\n// fake-as.mjs: test-only stand-in for an authorization server (RFC 8414 metadata + JWKS + a /mint endpoint).\nimport http from \"node:http\";\nimport { generateKeyPair, exportJWK, SignJWT } from \"jose\";\nconst { publicKey, privateKey } = await generateKeyPair(\"RS256\");\nconst jwk = { ...(await exportJWK(publicKey)), kid: \"k1\", alg: \"RS256\", use: \"sig\" };\nconst issuer = \"http://localhost:9000\";\nhttp.createServer(async (req, res) => {\n  const u = new URL(req.url, issuer);\n  const json = (o) => { res.setHeader(\"content-type\", \"application/json\"); res.end(JSON.stringify(o)); };\n  if (u.pathname === \"/.well-known/oauth-authorization-server\") return json({\n    issuer, authorization_endpoint: issuer + \"/authorize\", token_endpoint: issuer + \"/token\",\n    jwks_uri: issuer + \"/jwks.json\", response_types_supported: [\"code\"], code_challenge_methods_supported: [\"S256\"] });\n  if (u.pathname === \"/jwks.json\") return json({ keys: [jwk] });\n  if (u.pathname === \"/mint\") {  // ?aud=...&scope=...\n    const t = await new SignJWT({ scope: u.searchParams.get(\"scope\"), client_id: \"test-client\" })\n      .setProtectedHeader({ alg: \"RS256\", kid: \"k1\" }).setIssuer(issuer).setSubject(\"user-1\")\n      .setAudience(u.searchParams.get(\"aud\")).setIssuedAt().setExpirationTime(\"5m\").sign(privateKey);\n    res.end(t); return;\n  }\n  res.statusCode = 404; res.end();\n}).listen(9000);\n```\n\n```bash\nnode fake-as.mjs &\nAS_ISSUER=http://localhost:9000 node server.mjs &\nM=http://localhost:3000/mcp\nH=(-H 'content-type: application/json' -H 'accept: application/json, text/event-stream')\nINIT='{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{},\"clientInfo\":{\"name\":\"t\",\"version\":\"1\"}}}'\n\ncurl -si -X POST $M \"${H[@]}\" -d \"$INIT\"                                   # 1. no token\ncurl -s http://localhost:3000/.well-known/oauth-protected-resource/mcp      # 2. metadata\nT=$(curl -s \"http://localhost:9000/mint?aud=https://other.example.com&scope=notes:read\")\ncurl -si -X POST $M \"${H[@]}\" -H \"authorization: Bearer $T\" -d \"$INIT\"     # 3. wrong audience\n```\n\nThe real responses, from a run on 2026-10-21:\n\n```http\n# 1. no token\nHTTP/1.1 401 Unauthorized\nWWW-Authenticate: Bearer error=\"invalid_token\", error_description=\"Missing Authorization header\", scope=\"notes:read\", resource_metadata=\"http://localhost:3000/.well-known/oauth-protected-resource/mcp\"\n\n# 2. metadata\n{\"resource\":\"http://localhost:3000/mcp\",\"authorization_servers\":[\"http://localhost:9000\"],\"scopes_supported\":[\"notes:read\",\"notes:write\"],\"resource_name\":\"Notes MCP\"}\n\n# 3. token minted for https://other.example.com\nHTTP/1.1 401 Unauthorized\nWWW-Authenticate: Bearer error=\"invalid_token\", error_description=\"ERR_JWT_CLAIM_VALIDATION_FAILED\", scope=\"notes:read\", resource_metadata=\"http://localhost:3000/.well-known/oauth-protected-resource/mcp\"\n\n# 4. right audience, scope \"profile\" only\nHTTP/1.1 403 Forbidden\nWWW-Authenticate: Bearer error=\"insufficient_scope\", error_description=\"Insufficient scope\", scope=\"notes:read\", resource_metadata=\"...\"\n\n# 5. notes:read token: initialize and list-notes return 200\n\n# 6. notes:read token calling add-note\nHTTP/1.1 403 Forbidden\nwww-authenticate: Bearer error=\"insufficient_scope\", error_description=\"Insufficient scope\", scope=\"notes:write\", resource_metadata=\"...\"\n\n# 7. token with \"notes:read notes:write\": add-note returns 200 {\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"ok\"}]}}\n```\n\nFor a full client-side check, point the MCP Inspector or a client from [Connect an agent to a remote MCP server](https://indexagentica.com/guides/connect-an-agent-to-a-remote-mcp-server/) at it once a real authorization server is behind it. The test server above has no `/authorize` or `/token`, so it cannot complete a browser flow.\n\n## Step 6: calling upstream APIs without token passthrough\n\nMany MCP servers wrap another API. The spec is blunt here. The token the client sent you is for *you*. You **MUST NOT** pass it through, and the upstream API needs \"a separate token, issued by the upstream authorization server\". You have two options:\n\n1. **Your server is an OAuth client of the upstream API.** Store the user's upstream tokens server-side, for example in a vault, keyed to the MCP user. [Agent auth and delegated access](https://indexagentica.com/guides/agent-auth-and-delegated-access/) shows this pattern.\n2. **Token exchange ([RFC 8693](https://indexagentica.com/entries/rfc-8693/)), when the same authorization server, or a federated one, protects both.** Your server trades the inbound token for a new one aimed at the upstream resource. The RFC's own example request:\n\n```http\nPOST /as/token.oauth2 HTTP/1.1\nHost: as.example.com\nAuthorization: Basic cnMwODpsb25nLXNlY3VyZS1yYW5kb20tc2VjcmV0\nContent-Type: application/x-www-form-urlencoded\n\ngrant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange\n&resource=https%3A%2F%2Fbackend.example.com%2Fapi\n&subject_token=accVkjcJyb4BWCxGsndESCJQbdFMogUC5PbRDqceLTC\n&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token\n```\n\nThe `resource` (or `audience`) parameter names the upstream service, so the new token is bound to it. Whether your authorization server supports token exchange, and for which clients, depends on the product. Check before you design around it. [Cross App Access](https://indexagentica.com/entries/cross-app-access/) (ID-JAG), for enterprise SSO, is built on token exchange.\n\n## Beyond the core: authorization extensions\n\nThe MCP project keeps optional, composable extensions in [MCP Authorization Extensions](https://indexagentica.com/entries/mcp-ext-auth/). On 2026-10-21 the repo listed **Enterprise-Managed Authorization** as stable and **Client Credentials** (machine-to-machine, no user present) as a draft. Neither changes what your server must do as a resource server. They change how clients get tokens.\n\n## Test it like an attacker\n\nOnce the auth works, try to break the tools behind it: prompt injection that makes an authorized user's agent misuse a tool, or object-level authorization gaps between users. [Open-source red-teaming tools for LLM apps and agents](https://indexagentica.com/compare/llm-red-teaming-tools/) compares tools that automate this, including MCP-specific tests.\n\n## Checklist\n\n- [ ] One canonical URI, used as `resource`, in the metadata and as the token audience.\n- [ ] RFC 9728 document served at the path-aware well-known URL, with `resource` exactly equal to the endpoint URL and at least one `authorization_servers` entry.\n- [ ] `401` with `resource_metadata` and `scope` for missing, invalid, expired or wrong-audience tokens.\n- [ ] Signature, issuer, `exp` and `aud` checked on every request (or introspection with the same checks).\n- [ ] `403 insufficient_scope` naming every scope the operation needs, with per-tool scopes where writes are riskier than reads.\n- [ ] No `offline_access` in `scopes_supported` or challenges.\n- [ ] Tokens never logged, never in query strings, never forwarded upstream.\n- [ ] HTTPS everywhere outside local testing. The spec requires HTTPS for all authorization server endpoints.\n- [ ] Host header validation on (`allowedHosts` in `createMcpExpressApp`).\n\n## Troubleshooting\n\n- **Client never starts the OAuth flow:** the 401 is missing `WWW-Authenticate` or `resource_metadata`, or a proxy strips the header. Check with `curl -i`.\n- **Client rejects your metadata:** `resource` differs from the URL the client used, often because of a trailing slash or `http` versus `https` behind a proxy. Set `MCP_URL` to the public URL.\n- **Every token gets `invalid_token`:** the audience in tokens does not equal your canonical URI. Configure the authorization server to put the `resource` value into `aud`, or set its default resource. Also check that `exp` is present.\n- **Audience check silently not happening (Express):** `@modelcontextprotocol/express` is older than your `@modelcontextprotocol/server`. Upgrade both together.\n",
  "html": "<p>The <a href=\"/entries/mcp-authorization/\">MCP authorization spec</a> has two halves. Clients run the OAuth flow; servers have to check what clients bring. Most write-ups cover the client side. This guide covers the server: what a remote MCP server has to publish, check and refuse so that any spec-compliant client can sign in to it, and so that a token meant for somebody else is turned away.</p>\n<p>Authorization is optional in MCP. When you add it, the spec says HTTP-based servers <strong>SHOULD</strong> follow it. STDIO servers <strong>SHOULD NOT</strong> follow it, and should read credentials from the environment instead. So this guide is about servers on Streamable HTTP.</p>\n<p>Everything below was checked against the 2026-07-28 revision of the spec. The server code was run end to end against a local test authorization server, and the outputs shown are the real ones.</p>\n<h2>What the spec asks of your server</h2>\n<p>Your MCP server is an OAuth 2.1 <strong>resource server</strong>. It does not issue tokens. An authorization server does that, and that can be your identity provider or a product built for it (see <a href=\"/compare/agent-identity-and-mcp-auth/\">Agent identity and MCP authorization platforms</a>). The server&#39;s own duties are:</p>\n<table><thead><tr><th scope=\"col\">Duty</th><th scope=\"col\">Spec level</th><th scope=\"col\">Source</th></tr></thead><tbody><tr><td>Publish Protected Resource Metadata with at least one <code>authorization_servers</code> entry</td><td>MUST</td><td>MCP spec, <a href=\"/entries/rfc-9728/\">RFC 9728</a></td></tr><tr><td>Point clients at it, either with <code>resource_metadata</code> in the <code>WWW-Authenticate</code> header of a 401 or at a well-known URI</td><td>MUST (one of the two)</td><td>Authorization Server Discovery</td></tr><tr><td>Validate access tokens as OAuth 2.1 Section 5.2 describes</td><td>MUST</td><td>Token Handling</td></tr><tr><td>Accept only tokens issued for this server, checked through the audience</td><td>MUST</td><td><a href=\"/entries/rfc-8707/\">RFC 8707</a> Section 2, Security Considerations</td></tr><tr><td>Return 401 for missing, invalid or expired tokens</td><td>MUST</td><td>Token Handling</td></tr><tr><td>Return 403 with <code>error=&quot;insufficient_scope&quot;</code> when scope is missing</td><td>SHOULD</td><td>Scope Challenge Handling</td></tr><tr><td>Include a <code>scope</code> parameter in the challenge</td><td>SHOULD</td><td>Scope Selection Strategy</td></tr><tr><td>Never pass the client&#39;s token on to upstream APIs</td><td>MUST NOT</td><td>Security Considerations</td></tr><tr><td>Leave <code>offline_access</code> out of the challenge and <code>scopes_supported</code></td><td>SHOULD NOT include it</td><td>Refresh Tokens</td></tr></tbody></table>\n<p>The client side, covering PKCE, <code>resource</code> in both requests, issuer checks and client registration through <a href=\"/entries/oauth-client-id-metadata-document/\">Client ID Metadata Documents</a>, is covered in <a href=\"/guides/agent-auth-and-delegated-access/\">Agent auth and delegated access, step by step</a>.</p>\n<figure class=\"diagram\"><pre class=\"mermaid\">sequenceDiagram\n  participant C as MCP client\n  participant M as Your MCP server (resource server)\n  participant A as Authorization server\n  C-&gt;&gt;M: POST /mcp (no token)\n  M--&gt;&gt;C: 401, WWW-Authenticate: Bearer resource_metadata=&quot;...&quot;, scope=&quot;notes:read&quot;\n  C-&gt;&gt;M: GET /.well-known/oauth-protected-resource/mcp\n  M--&gt;&gt;C: {&quot;resource&quot;: &quot;.../mcp&quot;, &quot;authorization_servers&quot;: [&quot;https://auth...&quot;]}\n  C-&gt;&gt;A: discovery, then authorization code + PKCE with resource=.../mcp\n  A--&gt;&gt;C: access token (aud = .../mcp)\n  C-&gt;&gt;M: POST /mcp, Authorization: Bearer ...\n  M-&gt;&gt;M: verify signature, issuer, expiry, audience, scope\n  M--&gt;&gt;C: MCP response</pre><figcaption>Mermaid diagram (source shown; not rendered client-side)</figcaption></figure>\n<h2>Step 1: pick the canonical URI</h2>\n<p>Everything hangs on one string: your server&#39;s <strong>canonical URI</strong>. It is the <code>resource</code> value in your metadata, the <code>resource</code> parameter clients send, and the audience your tokens must carry. The spec gives <code>https://mcp.example.com/mcp</code> and <code>https://mcp.example.com</code> as valid examples. It rules out a missing scheme or a fragment, and it says to drop the trailing slash unless the slash means something. Pick one and use it everywhere.</p>\n<h2>Step 2: publish protected resource metadata (RFC 9728)</h2>\n<p>The metadata document is JSON at a well-known path. RFC 9728 puts <code>/.well-known/oauth-protected-resource</code> between the host and any path, so an endpoint at <code>https://mcp.example.com/mcp</code> publishes at <code>https://mcp.example.com/.well-known/oauth-protected-resource/mcp</code>. The fields that matter for MCP:</p>\n<table><thead><tr><th scope=\"col\">Field</th><th scope=\"col\">RFC 9728</th><th scope=\"col\">Notes</th></tr></thead><tbody><tr><td><code>resource</code></td><td>REQUIRED</td><td>Your canonical URI. Clients MUST NOT use the document if it is not identical to the URL they used (RFC 9728 Section 3.3)</td></tr><tr><td><code>authorization_servers</code></td><td>OPTIONAL in RFC 9728, but the MCP spec requires at least one</td><td>Issuer identifiers of the authorization servers clients can use</td></tr><tr><td><code>scopes_supported</code></td><td>RECOMMENDED</td><td>The MCP spec treats it as the minimal set for basic use. Ask for more through step-up</td></tr><tr><td><code>resource_name</code></td><td>RECOMMENDED</td><td>Human-readable name</td></tr><tr><td><code>bearer_methods_supported</code></td><td>OPTIONAL</td><td>MCP only allows the <code>Authorization</code> header. Tokens MUST NOT go in the query string</td></tr></tbody></table>\n<p>A real one is Square&#39;s (<a href=\"/entries/square-mcp/\">Square MCP</a>), fetched on 2026-10-21. It has <code>&quot;resource&quot;: &quot;https://mcp.squareup.com/mcp&quot;</code>, <code>&quot;authorization_servers&quot;: [&quot;https://mcp.squareup.com&quot;]</code> and a long <code>scopes_supported</code> list such as <code>PAYMENTS_READ</code> and <code>ORDERS_WRITE</code>.</p>\n<h2>Step 3: challenge, verify, scope</h2>\n<p>Three responses do the work:</p>\n<ul><li><strong>No token, or a bad one:</strong> <code>401</code> with <code>WWW-Authenticate: Bearer resource_metadata=&quot;&lt;metadata URL&gt;&quot;</code>, plus <code>scope=&quot;...&quot;</code> for what this request needs. This header is what starts the client&#39;s OAuth flow.</li><li><strong>Valid token, wrong audience:</strong> also <code>401</code> (<code>invalid_token</code>). A token for another server is not a valid token here.</li><li><strong>Valid token, too little scope:</strong> <code>403</code> with <code>error=&quot;insufficient_scope&quot;</code>, the <code>scope</code> the operation needs and <code>resource_metadata</code> again. The spec asks you to name <em>all</em> scopes the operation needs in one challenge, not one at a time. It also says you must account for scope hierarchies when a broader scope implies a narrower one.</li></ul>\n<h2>Step 4: build it with the TypeScript SDK</h2>\n<p>The MCP TypeScript SDK v2 (<code>@modelcontextprotocol/server</code>, Apache-2.0) has the resource-server half built in. The SDK&#39;s own docs say the old v1 authorization-server helpers are frozen in <code>@modelcontextprotocol/server-legacy/auth</code>, and recommend a dedicated identity provider for new servers.</p>\n<pre><code class=\"language-bash\">npm install @modelcontextprotocol/server @modelcontextprotocol/express @modelcontextprotocol/node express jose zod</code></pre>\n<p>Tested with <code>@modelcontextprotocol/server</code> 2.3.1, <code>@modelcontextprotocol/express</code> 2.0.2, <code>@modelcontextprotocol/node</code> 2.1.1, <code>express</code> 5.3.0, <code>jose</code> 6.2.13 and <code>zod</code> 4.6.7. Upgrade <code>@modelcontextprotocol/express</code> together with <code>@modelcontextprotocol/server</code>. The SDK docs warn that <code>@modelcontextprotocol/express</code> 2.0.1 does not pass <code>expectedResource</code> on, so no audience check happens.</p>\n<pre><code class=\"language-js\">// server.mjs: an MCP server that is an OAuth 2.1 resource server (MCP authorization spec 2026-07-28).\nimport { createRemoteJWKSet, jwtVerify } from &quot;jose&quot;;\nimport {\n  createMcpExpressApp,\n  getOAuthProtectedResourceMetadataUrl,\n  mcpAuthMetadataRouter,\n  requireBearerAuth,\n} from &quot;@modelcontextprotocol/express&quot;;\nimport { toNodeHandler } from &quot;@modelcontextprotocol/node&quot;;\nimport { createMcpHandler, McpServer, OAuthError, OAuthErrorCode, requireScopes } from &quot;@modelcontextprotocol/server&quot;;\nimport * as z from &quot;zod/v4&quot;;\n\nconst PORT = Number(process.env.PORT ?? 3000);\nconst mcpServerUrl = new URL(process.env.MCP_URL ?? `http://localhost:${PORT}/mcp`); // canonical URI = token audience\nconst issuer = process.env.AS_ISSUER;                                                // e.g. https://auth.example.com\n\n// 1. Your authorization server&#39;s RFC 8414 metadata (fetched once at startup).\nconst oauthMetadata = await (await fetch(new URL(&quot;/.well-known/oauth-authorization-server&quot;, issuer))).json();\nif (oauthMetadata.issuer !== issuer) throw new Error(&quot;AS metadata issuer mismatch&quot;);\nconst jwks = createRemoteJWKSet(new URL(oauthMetadata.jwks_uri));\n\n// 2. Verify a JWT access token: signature, issuer, expiry and audience (RFC 8707).\nasync function verifyAccessToken(token) {\n  try {\n    const { payload } = await jwtVerify(token, jwks, { issuer, audience: mcpServerUrl.href });\n    return {\n      token,\n      clientId: payload.client_id ?? payload.sub,\n      scopes: typeof payload.scope === &quot;string&quot; ? payload.scope.split(&quot; &quot;) : [],\n      expiresAt: payload.exp,\n      resource: mcpServerUrl, // jwtVerify already checked aud; report it so expectedResource matches\n    };\n  } catch (e) {\n    throw new OAuthError(OAuthErrorCode.InvalidToken, e.code ?? &quot;invalid token&quot;);\n  }\n}\n\n// 3. The MCP server, with a per-tool scope on the write tool.\nconst notes = [];\nfunction buildServer() {\n  const server = new McpServer({ name: &quot;notes&quot;, version: &quot;1.0.0&quot; });\n  server.registerTool(&quot;list-notes&quot;, { description: &quot;List notes&quot;, inputSchema: z.object({}) }, async () =&gt; ({\n    content: [{ type: &quot;text&quot;, text: JSON.stringify(notes) }],\n  }));\n  server.registerTool(\n    &quot;add-note&quot;,\n    { description: &quot;Add a note&quot;, inputSchema: z.object({ text: z.string() }), scopeChallenge: requireScopes(&quot;notes:write&quot;) },\n    async ({ text }) =&gt; { notes.push(text); return { content: [{ type: &quot;text&quot;, text: &quot;ok&quot; }] }; },\n  );\n  return server;\n}\n\n// 4. Wire it up: RFC 9728 metadata + bearer gate in front of /mcp.\nconst app = createMcpExpressApp({ host: &quot;0.0.0.0&quot;, allowedHosts: [mcpServerUrl.hostname] });\napp.use(mcpAuthMetadataRouter({\n  oauthMetadata,\n  resourceServerUrl: mcpServerUrl,\n  scopesSupported: [&quot;notes:read&quot;, &quot;notes:write&quot;],\n  resourceName: &quot;Notes MCP&quot;,\n}));\nconst auth = requireBearerAuth({\n  verifier: { verifyAccessToken },\n  requiredScopes: [&quot;notes:read&quot;],\n  resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),\n  expectedResource: mcpServerUrl,\n});\nconst node = toNodeHandler(createMcpHandler(buildServer));\napp.all(&quot;/mcp&quot;, auth, (req, res) =&gt; void node(req, res, req.body));\napp.listen(PORT, () =&gt; console.error(`MCP server on ${mcpServerUrl}`));</code></pre>\n<p>What each piece does:</p>\n<ul><li><code>mcpAuthMetadataRouter</code> serves the RFC 9728 document at the path-aware location (<code>/.well-known/oauth-protected-resource/mcp</code>). It also mirrors your authorization server&#39;s metadata at <code>/.well-known/oauth-authorization-server</code> for clients that probe your origin.</li><li><code>requireBearerAuth</code> answers <code>401 invalid_token</code> for missing, malformed or expired tokens and <code>403 insufficient_scope</code> when <code>requiredScopes</code> is not met. Both carry the <code>resource_metadata</code> challenge. It also rejects a token whose <code>expiresAt</code> is unset, so always fill it in.</li><li><code>jwtVerify</code> with <code>issuer</code> and <code>audience</code> is the audience check the spec requires. <code>expectedResource</code> is the SDK&#39;s own second check on the <code>resource</code> your verifier reports.</li><li><code>scopeChallenge: requireScopes(&quot;notes:write&quot;)</code> gives one tool a stricter scope. The SDK runs it before the tool, and before input validation. Scope hierarchies are your job: the SDK docs say it &quot;does not infer hierarchies&quot;.</li><li>If your tokens are opaque rather than JWTs, replace <code>jwtVerify</code> with an RFC 7662 introspection call. Check <code>active</code>, <code>aud</code> and <code>exp</code> from the response, the same way.</li></ul>\n<p>On Cloudflare Workers, Deno, Bun or Hono, the same gate is <code>requireBearerAuth</code> from <code>@modelcontextprotocol/server</code>. It works on web-standard <code>Request</code> and <code>Response</code>, and <code>oauthMetadataResponse</code> serves the metadata.</p>\n<h2>Step 5: test it without a real identity provider</h2>\n<p>To test, you need an authorization server that publishes metadata and a JWKS and can mint tokens. This short stand-in (test only, never deploy it) does exactly that with <code>jose</code>:</p>\n<pre><code class=\"language-js\">// fake-as.mjs: test-only stand-in for an authorization server (RFC 8414 metadata + JWKS + a /mint endpoint).\nimport http from &quot;node:http&quot;;\nimport { generateKeyPair, exportJWK, SignJWT } from &quot;jose&quot;;\nconst { publicKey, privateKey } = await generateKeyPair(&quot;RS256&quot;);\nconst jwk = { ...(await exportJWK(publicKey)), kid: &quot;k1&quot;, alg: &quot;RS256&quot;, use: &quot;sig&quot; };\nconst issuer = &quot;http://localhost:9000&quot;;\nhttp.createServer(async (req, res) =&gt; {\n  const u = new URL(req.url, issuer);\n  const json = (o) =&gt; { res.setHeader(&quot;content-type&quot;, &quot;application/json&quot;); res.end(JSON.stringify(o)); };\n  if (u.pathname === &quot;/.well-known/oauth-authorization-server&quot;) return json({\n    issuer, authorization_endpoint: issuer + &quot;/authorize&quot;, token_endpoint: issuer + &quot;/token&quot;,\n    jwks_uri: issuer + &quot;/jwks.json&quot;, response_types_supported: [&quot;code&quot;], code_challenge_methods_supported: [&quot;S256&quot;] });\n  if (u.pathname === &quot;/jwks.json&quot;) return json({ keys: [jwk] });\n  if (u.pathname === &quot;/mint&quot;) {  // ?aud=...&amp;scope=...\n    const t = await new SignJWT({ scope: u.searchParams.get(&quot;scope&quot;), client_id: &quot;test-client&quot; })\n      .setProtectedHeader({ alg: &quot;RS256&quot;, kid: &quot;k1&quot; }).setIssuer(issuer).setSubject(&quot;user-1&quot;)\n      .setAudience(u.searchParams.get(&quot;aud&quot;)).setIssuedAt().setExpirationTime(&quot;5m&quot;).sign(privateKey);\n    res.end(t); return;\n  }\n  res.statusCode = 404; res.end();\n}).listen(9000);</code></pre>\n<pre><code class=\"language-bash\">node fake-as.mjs &amp;\nAS_ISSUER=http://localhost:9000 node server.mjs &amp;\nM=http://localhost:3000/mcp\nH=(-H &#39;content-type: application/json&#39; -H &#39;accept: application/json, text/event-stream&#39;)\nINIT=&#39;{&quot;jsonrpc&quot;:&quot;2.0&quot;,&quot;id&quot;:1,&quot;method&quot;:&quot;initialize&quot;,&quot;params&quot;:{&quot;protocolVersion&quot;:&quot;2025-11-25&quot;,&quot;capabilities&quot;:{},&quot;clientInfo&quot;:{&quot;name&quot;:&quot;t&quot;,&quot;version&quot;:&quot;1&quot;}}}&#39;\n\ncurl -si -X POST $M &quot;${H[@]}&quot; -d &quot;$INIT&quot;                                   # 1. no token\ncurl -s http://localhost:3000/.well-known/oauth-protected-resource/mcp      # 2. metadata\nT=$(curl -s &quot;http://localhost:9000/mint?aud=https://other.example.com&amp;scope=notes:read&quot;)\ncurl -si -X POST $M &quot;${H[@]}&quot; -H &quot;authorization: Bearer $T&quot; -d &quot;$INIT&quot;     # 3. wrong audience</code></pre>\n<p>The real responses, from a run on 2026-10-21:</p>\n<pre><code class=\"language-http\"># 1. no token\nHTTP/1.1 401 Unauthorized\nWWW-Authenticate: Bearer error=&quot;invalid_token&quot;, error_description=&quot;Missing Authorization header&quot;, scope=&quot;notes:read&quot;, resource_metadata=&quot;http://localhost:3000/.well-known/oauth-protected-resource/mcp&quot;\n\n# 2. metadata\n{&quot;resource&quot;:&quot;http://localhost:3000/mcp&quot;,&quot;authorization_servers&quot;:[&quot;http://localhost:9000&quot;],&quot;scopes_supported&quot;:[&quot;notes:read&quot;,&quot;notes:write&quot;],&quot;resource_name&quot;:&quot;Notes MCP&quot;}\n\n# 3. token minted for https://other.example.com\nHTTP/1.1 401 Unauthorized\nWWW-Authenticate: Bearer error=&quot;invalid_token&quot;, error_description=&quot;ERR_JWT_CLAIM_VALIDATION_FAILED&quot;, scope=&quot;notes:read&quot;, resource_metadata=&quot;http://localhost:3000/.well-known/oauth-protected-resource/mcp&quot;\n\n# 4. right audience, scope &quot;profile&quot; only\nHTTP/1.1 403 Forbidden\nWWW-Authenticate: Bearer error=&quot;insufficient_scope&quot;, error_description=&quot;Insufficient scope&quot;, scope=&quot;notes:read&quot;, resource_metadata=&quot;...&quot;\n\n# 5. notes:read token: initialize and list-notes return 200\n\n# 6. notes:read token calling add-note\nHTTP/1.1 403 Forbidden\nwww-authenticate: Bearer error=&quot;insufficient_scope&quot;, error_description=&quot;Insufficient scope&quot;, scope=&quot;notes:write&quot;, resource_metadata=&quot;...&quot;\n\n# 7. token with &quot;notes:read notes:write&quot;: add-note returns 200 {&quot;result&quot;:{&quot;content&quot;:[{&quot;type&quot;:&quot;text&quot;,&quot;text&quot;:&quot;ok&quot;}]}}</code></pre>\n<p>For a full client-side check, point the MCP Inspector or a client from <a href=\"/guides/connect-an-agent-to-a-remote-mcp-server/\">Connect an agent to a remote MCP server</a> at it once a real authorization server is behind it. The test server above has no <code>/authorize</code> or <code>/token</code>, so it cannot complete a browser flow.</p>\n<h2>Step 6: calling upstream APIs without token passthrough</h2>\n<p>Many MCP servers wrap another API. The spec is blunt here. The token the client sent you is for <em>you</em>. You <strong>MUST NOT</strong> pass it through, and the upstream API needs &quot;a separate token, issued by the upstream authorization server&quot;. You have two options:</p>\n<ol><li><strong>Your server is an OAuth client of the upstream API.</strong> Store the user&#39;s upstream tokens server-side, for example in a vault, keyed to the MCP user. <a href=\"/guides/agent-auth-and-delegated-access/\">Agent auth and delegated access</a> shows this pattern.</li><li><strong>Token exchange (<a href=\"/entries/rfc-8693/\">RFC 8693</a>), when the same authorization server, or a federated one, protects both.</strong> Your server trades the inbound token for a new one aimed at the upstream resource. The RFC&#39;s own example request:</li></ol>\n<pre><code class=\"language-http\">POST /as/token.oauth2 HTTP/1.1\nHost: as.example.com\nAuthorization: Basic cnMwODpsb25nLXNlY3VyZS1yYW5kb20tc2VjcmV0\nContent-Type: application/x-www-form-urlencoded\n\ngrant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange\n&amp;resource=https%3A%2F%2Fbackend.example.com%2Fapi\n&amp;subject_token=accVkjcJyb4BWCxGsndESCJQbdFMogUC5PbRDqceLTC\n&amp;subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token</code></pre>\n<p>The <code>resource</code> (or <code>audience</code>) parameter names the upstream service, so the new token is bound to it. Whether your authorization server supports token exchange, and for which clients, depends on the product. Check before you design around it. <a href=\"/entries/cross-app-access/\">Cross App Access</a> (ID-JAG), for enterprise SSO, is built on token exchange.</p>\n<h2>Beyond the core: authorization extensions</h2>\n<p>The MCP project keeps optional, composable extensions in <a href=\"/entries/mcp-ext-auth/\">MCP Authorization Extensions</a>. On 2026-10-21 the repo listed <strong>Enterprise-Managed Authorization</strong> as stable and <strong>Client Credentials</strong> (machine-to-machine, no user present) as a draft. Neither changes what your server must do as a resource server. They change how clients get tokens.</p>\n<h2>Test it like an attacker</h2>\n<p>Once the auth works, try to break the tools behind it: prompt injection that makes an authorized user&#39;s agent misuse a tool, or object-level authorization gaps between users. <a href=\"/compare/llm-red-teaming-tools/\">Open-source red-teaming tools for LLM apps and agents</a> compares tools that automate this, including MCP-specific tests.</p>\n<h2>Checklist</h2>\n<ul><li>☐ One canonical URI, used as <code>resource</code>, in the metadata and as the token audience.</li><li>☐ RFC 9728 document served at the path-aware well-known URL, with <code>resource</code> exactly equal to the endpoint URL and at least one <code>authorization_servers</code> entry.</li><li>☐ <code>401</code> with <code>resource_metadata</code> and <code>scope</code> for missing, invalid, expired or wrong-audience tokens.</li><li>☐ Signature, issuer, <code>exp</code> and <code>aud</code> checked on every request (or introspection with the same checks).</li><li>☐ <code>403 insufficient_scope</code> naming every scope the operation needs, with per-tool scopes where writes are riskier than reads.</li><li>☐ No <code>offline_access</code> in <code>scopes_supported</code> or challenges.</li><li>☐ Tokens never logged, never in query strings, never forwarded upstream.</li><li>☐ HTTPS everywhere outside local testing. The spec requires HTTPS for all authorization server endpoints.</li><li>☐ Host header validation on (<code>allowedHosts</code> in <code>createMcpExpressApp</code>).</li></ul>\n<h2>Troubleshooting</h2>\n<ul><li><strong>Client never starts the OAuth flow:</strong> the 401 is missing <code>WWW-Authenticate</code> or <code>resource_metadata</code>, or a proxy strips the header. Check with <code>curl -i</code>.</li><li><strong>Client rejects your metadata:</strong> <code>resource</code> differs from the URL the client used, often because of a trailing slash or <code>http</code> versus <code>https</code> behind a proxy. Set <code>MCP_URL</code> to the public URL.</li><li><strong>Every token gets <code>invalid_token</code>:</strong> the audience in tokens does not equal your canonical URI. Configure the authorization server to put the <code>resource</code> value into <code>aud</code>, or set its default resource. Also check that <code>exp</code> is present.</li><li><strong>Audience check silently not happening (Express):</strong> <code>@modelcontextprotocol/express</code> is older than your <code>@modelcontextprotocol/server</code>. Upgrade both together.</li></ul>"
}
