Index Agentica

Add OAuth authorization to your own MCP server

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.

Type
Guide
Author
Agentica Author
Published
Last verified
Difficulty
intermediate
Time
45 min

Prerequisites

The MCP authorization spec 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.

Authorization 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.

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.

What the spec asks of your server

Your 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). The server's own duties are:

DutySpec levelSource
Publish Protected Resource Metadata with at least one authorization_servers entryMUSTMCP spec, RFC 9728
Point clients at it, either with resource_metadata in the WWW-Authenticate header of a 401 or at a well-known URIMUST (one of the two)Authorization Server Discovery
Validate access tokens as OAuth 2.1 Section 5.2 describesMUSTToken Handling
Accept only tokens issued for this server, checked through the audienceMUSTRFC 8707 Section 2, Security Considerations
Return 401 for missing, invalid or expired tokensMUSTToken Handling
Return 403 with error="insufficient_scope" when scope is missingSHOULDScope Challenge Handling
Include a scope parameter in the challengeSHOULDScope Selection Strategy
Never pass the client's token on to upstream APIsMUST NOTSecurity Considerations
Leave offline_access out of the challenge and scopes_supportedSHOULD NOT include itRefresh Tokens

The client side, covering PKCE, resource in both requests, issuer checks and client registration through Client ID Metadata Documents, is covered in Agent auth and delegated access, step by step.

sequenceDiagram
  participant C as MCP client
  participant M as Your MCP server (resource server)
  participant A as Authorization server
  C->>M: POST /mcp (no token)
  M-->>C: 401, WWW-Authenticate: Bearer resource_metadata="...", scope="notes:read"
  C->>M: GET /.well-known/oauth-protected-resource/mcp
  M-->>C: {"resource": ".../mcp", "authorization_servers": ["https://auth..."]}
  C->>A: discovery, then authorization code + PKCE with resource=.../mcp
  A-->>C: access token (aud = .../mcp)
  C->>M: POST /mcp, Authorization: Bearer ...
  M->>M: verify signature, issuer, expiry, audience, scope
  M-->>C: MCP response
Mermaid diagram (source shown; not rendered client-side)

Step 1: pick the canonical URI

Everything 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.

Step 2: publish protected resource metadata (RFC 9728)

The 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:

FieldRFC 9728Notes
resourceREQUIREDYour canonical URI. Clients MUST NOT use the document if it is not identical to the URL they used (RFC 9728 Section 3.3)
authorization_serversOPTIONAL in RFC 9728, but the MCP spec requires at least oneIssuer identifiers of the authorization servers clients can use
scopes_supportedRECOMMENDEDThe MCP spec treats it as the minimal set for basic use. Ask for more through step-up
resource_nameRECOMMENDEDHuman-readable name
bearer_methods_supportedOPTIONALMCP only allows the Authorization header. Tokens MUST NOT go in the query string

A real one is Square's (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.

Step 3: challenge, verify, scope

Three responses do the work:

Step 4: build it with the TypeScript SDK

The 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.

npm install @modelcontextprotocol/server @modelcontextprotocol/express @modelcontextprotocol/node express jose zod

Tested 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.

// server.mjs: an MCP server that is an OAuth 2.1 resource server (MCP authorization spec 2026-07-28).
import { createRemoteJWKSet, jwtVerify } from "jose";
import {
  createMcpExpressApp,
  getOAuthProtectedResourceMetadataUrl,
  mcpAuthMetadataRouter,
  requireBearerAuth,
} from "@modelcontextprotocol/express";
import { toNodeHandler } from "@modelcontextprotocol/node";
import { createMcpHandler, McpServer, OAuthError, OAuthErrorCode, requireScopes } from "@modelcontextprotocol/server";
import * as z from "zod/v4";

const PORT = Number(process.env.PORT ?? 3000);
const mcpServerUrl = new URL(process.env.MCP_URL ?? `http://localhost:${PORT}/mcp`); // canonical URI = token audience
const issuer = process.env.AS_ISSUER;                                                // e.g. https://auth.example.com

// 1. Your authorization server's RFC 8414 metadata (fetched once at startup).
const oauthMetadata = await (await fetch(new URL("/.well-known/oauth-authorization-server", issuer))).json();
if (oauthMetadata.issuer !== issuer) throw new Error("AS metadata issuer mismatch");
const jwks = createRemoteJWKSet(new URL(oauthMetadata.jwks_uri));

// 2. Verify a JWT access token: signature, issuer, expiry and audience (RFC 8707).
async function verifyAccessToken(token) {
  try {
    const { payload } = await jwtVerify(token, jwks, { issuer, audience: mcpServerUrl.href });
    return {
      token,
      clientId: payload.client_id ?? payload.sub,
      scopes: typeof payload.scope === "string" ? payload.scope.split(" ") : [],
      expiresAt: payload.exp,
      resource: mcpServerUrl, // jwtVerify already checked aud; report it so expectedResource matches
    };
  } catch (e) {
    throw new OAuthError(OAuthErrorCode.InvalidToken, e.code ?? "invalid token");
  }
}

// 3. The MCP server, with a per-tool scope on the write tool.
const notes = [];
function buildServer() {
  const server = new McpServer({ name: "notes", version: "1.0.0" });
  server.registerTool("list-notes", { description: "List notes", inputSchema: z.object({}) }, async () => ({
    content: [{ type: "text", text: JSON.stringify(notes) }],
  }));
  server.registerTool(
    "add-note",
    { description: "Add a note", inputSchema: z.object({ text: z.string() }), scopeChallenge: requireScopes("notes:write") },
    async ({ text }) => { notes.push(text); return { content: [{ type: "text", text: "ok" }] }; },
  );
  return server;
}

// 4. Wire it up: RFC 9728 metadata + bearer gate in front of /mcp.
const app = createMcpExpressApp({ host: "0.0.0.0", allowedHosts: [mcpServerUrl.hostname] });
app.use(mcpAuthMetadataRouter({
  oauthMetadata,
  resourceServerUrl: mcpServerUrl,
  scopesSupported: ["notes:read", "notes:write"],
  resourceName: "Notes MCP",
}));
const auth = requireBearerAuth({
  verifier: { verifyAccessToken },
  requiredScopes: ["notes:read"],
  resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
  expectedResource: mcpServerUrl,
});
const node = toNodeHandler(createMcpHandler(buildServer));
app.all("/mcp", auth, (req, res) => void node(req, res, req.body));
app.listen(PORT, () => console.error(`MCP server on ${mcpServerUrl}`));

What each piece does:

On 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.

Step 5: test it without a real identity provider

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 jose:

// fake-as.mjs: test-only stand-in for an authorization server (RFC 8414 metadata + JWKS + a /mint endpoint).
import http from "node:http";
import { generateKeyPair, exportJWK, SignJWT } from "jose";
const { publicKey, privateKey } = await generateKeyPair("RS256");
const jwk = { ...(await exportJWK(publicKey)), kid: "k1", alg: "RS256", use: "sig" };
const issuer = "http://localhost:9000";
http.createServer(async (req, res) => {
  const u = new URL(req.url, issuer);
  const json = (o) => { res.setHeader("content-type", "application/json"); res.end(JSON.stringify(o)); };
  if (u.pathname === "/.well-known/oauth-authorization-server") return json({
    issuer, authorization_endpoint: issuer + "/authorize", token_endpoint: issuer + "/token",
    jwks_uri: issuer + "/jwks.json", response_types_supported: ["code"], code_challenge_methods_supported: ["S256"] });
  if (u.pathname === "/jwks.json") return json({ keys: [jwk] });
  if (u.pathname === "/mint") {  // ?aud=...&scope=...
    const t = await new SignJWT({ scope: u.searchParams.get("scope"), client_id: "test-client" })
      .setProtectedHeader({ alg: "RS256", kid: "k1" }).setIssuer(issuer).setSubject("user-1")
      .setAudience(u.searchParams.get("aud")).setIssuedAt().setExpirationTime("5m").sign(privateKey);
    res.end(t); return;
  }
  res.statusCode = 404; res.end();
}).listen(9000);
node fake-as.mjs &
AS_ISSUER=http://localhost:9000 node server.mjs &
M=http://localhost:3000/mcp
H=(-H 'content-type: application/json' -H 'accept: application/json, text/event-stream')
INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}'

curl -si -X POST $M "${H[@]}" -d "$INIT"                                   # 1. no token
curl -s http://localhost:3000/.well-known/oauth-protected-resource/mcp      # 2. metadata
T=$(curl -s "http://localhost:9000/mint?aud=https://other.example.com&scope=notes:read")
curl -si -X POST $M "${H[@]}" -H "authorization: Bearer $T" -d "$INIT"     # 3. wrong audience

The real responses, from a run on 2026-10-21:

# 1. no token
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="Missing Authorization header", scope="notes:read", resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource/mcp"

# 2. metadata
{"resource":"http://localhost:3000/mcp","authorization_servers":["http://localhost:9000"],"scopes_supported":["notes:read","notes:write"],"resource_name":"Notes MCP"}

# 3. token minted for https://other.example.com
HTTP/1.1 401 Unauthorized
WWW-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"

# 4. right audience, scope "profile" only
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", error_description="Insufficient scope", scope="notes:read", resource_metadata="..."

# 5. notes:read token: initialize and list-notes return 200

# 6. notes:read token calling add-note
HTTP/1.1 403 Forbidden
www-authenticate: Bearer error="insufficient_scope", error_description="Insufficient scope", scope="notes:write", resource_metadata="..."

# 7. token with "notes:read notes:write": add-note returns 200 {"result":{"content":[{"type":"text","text":"ok"}]}}

For a full client-side check, point the MCP Inspector or a client from 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.

Step 6: calling upstream APIs without token passthrough

Many 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:

  1. 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 shows this pattern.
  2. Token exchange (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:
POST /as/token.oauth2 HTTP/1.1
Host: as.example.com
Authorization: Basic cnMwODpsb25nLXNlY3VyZS1yYW5kb20tc2VjcmV0
Content-Type: application/x-www-form-urlencoded

grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange
&resource=https%3A%2F%2Fbackend.example.com%2Fapi
&subject_token=accVkjcJyb4BWCxGsndESCJQbdFMogUC5PbRDqceLTC
&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token

The 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 (ID-JAG), for enterprise SSO, is built on token exchange.

Beyond the core: authorization extensions

The MCP project keeps optional, composable extensions in MCP Authorization Extensions. 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.

Test it like an attacker

Once 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 compares tools that automate this, including MCP-specific tests.

Checklist

Troubleshooting

Directory entries in this guide

Related

Sources

Machine-readable