Remote MCP and Authorization

By the Syncfusion DevTutorials team · Last updated

How remote MCP servers use OAuth 2.1: 401 challenges, protected resource metadata, auth server discovery, client registration, PKCE, the resource parameter, scopes and audience checks.

Think about entering an office building. At the front desk, you show your ID and say who you are visiting. The guard checks your identity, calls the right office, and gives you a visitor badge for that building and that day only. The badge opens only the doors it was made for, and it stops working if you try to use it somewhere else. That is a good mental model for remote MCP authorization: the server must prove who is calling and which permissions they have before it lets them in.

A remote MCP server works the same way. A client must get a token from an authorization server for this server only, with limited scopes, and send it with every request. The point is simple: a remote server cannot trust the local machine or a hidden environment; it must verify the caller over the network.

The main idea: a remote MCP server is an OAuth 2.1 resource server. It answers 401 with a pointer to its metadata. The client discovers the authorization server, signs the user in with PKCE and the resource parameter, checks iss, and sends a Bearer token on every request. The server checks the token's audience and scopes, and never passes tokens through.

A Real-World Example

Brightgrid opens its help-desk server to customers' own AI apps, at https://mcp.brightgrid.example/mcp.

  1. A customer's AI app connects. The server answers 401 with a link to its protected resource metadata.
  2. The metadata names Brightgrid's authorization server.
  3. The app sends the customer to Brightgrid's sign-in page. The customer signs in and approves "read your orders".
  4. The app receives a token that is valid only for the help-desk server, with the scope orders:read.
  5. Later the customer asks to cancel an order. The server answers 403 insufficient_scope, asking for orders:write. The app asks the customer to approve that too.

This is the same pattern as the badge example: a caller gets a limited credential, and the server checks it on each request before allowing access.

In simple words: show your badge every time, and the badge only opens the doors it was made for.

What You Will Learn

In this lesson, you will learn:

  1. What changes when a server is remote, hosted, or multi-tenant.
  2. The HTTP endpoint rules.
  3. The OAuth 2.1 flow, step by step.
  4. How client registration works, including Client ID Metadata Documents.
  5. How servers check tokens, audiences, and scopes.
  6. Why token passthrough is forbidden.

The MCP Lab's Raw request tab now has a protected copy of the server at /secure/mcp. Its examples show a 401 challenge, the resource metadata, a valid token, a token for another server, and a missing scope. The lab's tokens are fake labels, but the status codes and headers match the spec.

The authorization flow: 401 with resource_metadata, discovery of the authorization server, sign-in with PKCE and the resource parameter, then a Bearer token on every MCP request.

Remote Servers

A remote server is not a local process running in the same trusted environment. It is a networked service, so the host or app must prove who is calling and what that caller is allowed to do. That is why the authorization checks matter so much here.

Hosted, Multi-Tenant, Enterprise

  • A hosted server runs on the web, so anyone with the URL can try to reach it.
  • A multi-tenant server serves many customers or companies at once. Every request must stay inside its own tenant's data.
  • An enterprise server sits inside a company, often behind a gateway, with company sign-in.

All of them must answer one question on every request: who is calling, and what may they do?

HTTP Endpoint Rules

Remote servers should behave like any other protected web API. That means:

  • Use HTTPS everywhere except localhost during development.
  • Check the Origin header to block calls from unexpected web pages.
  • Send the token in the Authorization: Bearer ... header on every request, never in the URL.
  • For stdio servers, don't use this flow: they get credentials from their environment, as you set up in Chapter 6.

The goal is to stop accidental exposure and prevent one caller from reusing credentials that were meant for a different server or app.

The Flow, Step by Step

Now that the client knows the server is protected, it must discover where to authenticate, obtain a token, and use that token on future requests. The flow is predictable and repeatable: the server asks the client to authenticate, the client discovers where the user can sign in, the user grants access, the authorization server issues a token, and the client sends it with future requests.

1. The 401 Challenge

A request without a valid token gets 401 Unauthorized. The WWW-Authenticate header points to the server's protected resource metadata, and it may also say which scopes to ask for. This is the server's way of saying, "I don't know who you are yet, so start the auth flow here."

Read the 401 challenge

// Step 1: the server says 401 and points to its metadata.
const header =
  'Bearer resource_metadata="https://mcp.brightgrid.example/.well-known/oauth-protected-resource", scope="orders:read"';

function parseChallenge(h) {
  const [scheme, ...rest] = h.split(" ");
  const params = {};
  for (const m of rest.join(" ").matchAll(/(\w+)="([^"]*)"/g)) {
    params[m[1]] = m[2];
  }
  return { scheme, params };
}

const c = parseChallenge(header);
console.log("scheme:           ", c.scheme);
console.log("resource_metadata:", c.params.resource_metadata);
console.log("scope to request: ", c.params.scope);

Expected output:

scheme:            Bearer
resource_metadata: https://mcp.brightgrid.example/.well-known/oauth-protected-resource
scope to request:  orders:read

2. Protected Resource Metadata

The protected resource metadata (RFC 9728) is a small JSON document from the MCP server. It must list at least one entry in authorization_servers. It can also list scopes_supported. If the 401 has no resource_metadata link, the client tries well-known addresses such as /.well-known/oauth-protected-resource.

This metadata is the server's public address card: it tells the client which authorization server can issue a token for this MCP resource.

3. Authorization Server Discovery

The client then fetches the authorization server's own metadata from well-known addresses, trying the OAuth form and the OpenID Connect form in a fixed order. It must check that the issuer in the document matches the address it asked.

This protects against a mix-up where the client is redirected to a different authorization server than the one it originally discovered.

Where to find the authorization server's metadata

// Step 2: from protected resource metadata to the authorization server's metadata URLs.
const prm = {
  resource: "https://mcp.brightgrid.example/mcp",
  authorization_servers: ["https://auth.brightgrid.example/tenant1"],
  scopes_supported: ["orders:read", "orders:write"],
};

function metadataUrls(issuer) {
  const u = new URL(issuer);
  const path = u.pathname === "/" ? "" : u.pathname;
  if (!path) {
    return [
      `${u.origin}/.well-known/oauth-authorization-server`,
      `${u.origin}/.well-known/openid-configuration`,
    ];
  }
  return [
    `${u.origin}/.well-known/oauth-authorization-server${path}`,
    `${u.origin}/.well-known/openid-configuration${path}`,
    `${u.origin}${path}/.well-known/openid-configuration`,
  ];
}

metadataUrls(prm.authorization_servers[0]).forEach((url, i) =>
  console.log(`${i + 1}. ${url}`),
);
console.log("no path:", metadataUrls("https://auth.brightgrid.example")[0]);

Expected output:

1. https://auth.brightgrid.example/.well-known/oauth-authorization-server/tenant1
2. https://auth.brightgrid.example/.well-known/openid-configuration/tenant1
3. https://auth.brightgrid.example/tenant1/.well-known/openid-configuration
no path: https://auth.brightgrid.example/.well-known/oauth-authorization-server

4. Client Registration

The authorization server must know the client. There are three ways, in this order of preference:

  1. Pre-registration: the client already has a client id for this authorization server.
  2. Client ID Metadata Documents (CIMD): the client's id is an HTTPS URL, like https://app.example/oauth/client.json, pointing to a JSON document with client_id, client_name, and redirect_uris. The authorization server fetches it. This is the recommended way when client and server have never met. Servers advertise it with client_id_metadata_document_supported.
  3. Dynamic Client Registration: the client registers itself through an API. This is deprecated in 2026-07-28 and kept only for older authorization servers.

The important point is not the exact registration API; it is that the authorization server must know which app is asking before it can safely issue tokens.

5. Sign-In with PKCE and resource

The client opens the user's browser at the authorization server, with:

  • PKCE: a secret code_verifier stays with the client. Only its SHA-256 hash, the code_challenge, goes in the request. A stolen authorization code is useless without the verifier.
  • resource: the canonical URI of the MCP server (RFC 8707). The token will be made for that server only.
  • scope: only what is needed now.

This is the point where user consent, app identity, and server binding come together.

Look at a PKCE pair

// Step 3: PKCE. The client keeps a secret verifier and sends only its SHA-256 hash.
// Real clients compute the challenge with crypto.subtle.digest("SHA-256", ...).
// Here we use a pair made ahead of time so the output never changes.
const pkce = {
  verifier: "4l6qBncw5ry2UgtychX8ekq5tadtZ-GycTMsd3Lb5BA",
  challenge: "d6hBwkmV2U8Nh-aQ7wPfeq5LDTdKPDZvMxF1GbD5zoA",
  method: "S256",
};

const urlSafe = (s) => /^[A-Za-z0-9_-]+$/.test(s);

console.log("code_verifier length: ", pkce.verifier.length, "(kept secret by the client)");
console.log("code_challenge length:", pkce.challenge.length, "(sent in the authorization request)");
console.log("code_challenge_method:", pkce.method);
console.log("different values:     ", pkce.verifier !== pkce.challenge);
console.log("both URL-safe:        ", urlSafe(pkce.verifier) && urlSafe(pkce.challenge));

Expected output:

code_verifier length:  43 (kept secret by the client)
code_challenge length: 43 (sent in the authorization request)
code_challenge_method: S256
different values:      true
both URL-safe:         true

The authorization request

// Step 4: the authorization request carries resource, PKCE and scope.
const params = new URLSearchParams({
  response_type: "code",
  client_id: "https://app.brightgrid.example/oauth/client.json", // a Client ID Metadata Document URL
  redirect_uri: "http://127.0.0.1:33418/callback",
  scope: "orders:read",
  resource: "https://mcp.brightgrid.example/mcp", // RFC 8707: which MCP server the token is for
  code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
  code_challenge_method: "S256",
  state: "x7f2k9",
});

const url = "https://auth.brightgrid.example/authorize?" + params;
for (const [k, v] of new URL(url).searchParams) {
  console.log(k.padEnd(22), v);
}

Expected output:

response_type          code
client_id              https://app.brightgrid.example/oauth/client.json
redirect_uri           http://127.0.0.1:33418/callback
scope                  orders:read
resource               https://mcp.brightgrid.example/mcp
code_challenge         E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
code_challenge_method  S256
state                  x7f2k9

6. The Redirect and iss

After the user approves, the browser comes back with a code, and, from servers that support it, an iss value naming the authorization server. The client compares iss with the issuer it recorded before redirecting. A mismatch means a mix-up attack: a bad server trying to steal a code meant for another. Then the client exchanges the code (with the code_verifier and resource) for an access token.

The client must not trust the redirect alone. It needs to validate the issuer, or a malicious site could swap one authentication flow for another.

7. Every Request Carries the Token

From now on, every MCP request has Authorization: Bearer <token>.

Server-Side Checks

Audience and Expiry

The MCP server must check every token:

  • It is valid and not expired.
  • It was issued for this server: its audience matches the server's own resource URI.
  • Otherwise: 401.

The reason is straightforward: a valid token for one resource should not be accepted by another one.

Check iss on the client, audience on the server

// Step 5 (client): check iss in the redirect. Step 6 (server): check the token's audience.
function checkIss(recordedIssuer, supported, iss) {
  if (iss !== undefined) {
    return iss === recordedIssuer
      ? "ok"
      : "reject: issuer mismatch (possible mix-up attack)";
  }
  return supported ? "reject: iss missing" : "proceed";
}
const recorded = "https://auth.brightgrid.example";
console.log("iss match:   ", checkIss(recorded, true, "https://auth.brightgrid.example"));
console.log("iss other:   ", checkIss(recorded, true, "https://evil-auth.example"));
console.log("iss missing: ", checkIss(recorded, true, undefined));

function acceptToken(token, myResource) {
  if (token.exp * 1000 < Date.parse("2026-10-09T10:00:00Z")) {
    return "401: expired";
  }
  return token.aud === myResource ? "accept" : "401: token is for another server";
}
const me = "https://mcp.brightgrid.example/mcp";
console.log("token for me:   ", acceptToken({ aud: me, exp: 1791600000 }, me));
console.log(
  "token for other:",
  acceptToken({ aud: "https://api.github.com", exp: 1791600000 }, me),
);

Expected output:

iss match:    ok
iss other:    reject: issuer mismatch (possible mix-up attack)
iss missing:  reject: iss missing
token for me:    accept
token for other: 401: token is for another server

Scopes and Step-Up

If the token is valid but lacks a needed scope, the server answers 403 Forbidden with error="insufficient_scope" and the scopes it needs. The client then asks the user to approve those scopes too, keeping the ones it already has. This is called step-up authorization.

This is the least-privilege pattern in action: ask for the smallest permission first, then request more only when the task needs it.

Start with small scopes, like orders:read, and ask for more only when a tool needs them. Users approve small requests more easily, and a stolen token can do less.

Token Passthrough

Why It Is Forbidden

Sometimes an MCP server calls another API on the user's behalf, like a ticket system. It might be tempting to take the token the client sent and forward it to that API. This is token passthrough, and the spec forbids it:

  • The MCP server must only accept tokens issued for itself.
  • It must never forward the client's token to another service.
  • To call another API, the server gets its own token for that API through its own OAuth client.

Passthrough breaks audit trails, skips the other API's controls, and lets one stolen token open many doors.

A server that accepts any token "because the downstream API will check it" is a confused deputy waiting to happen. Validate the audience yourself, every time.

Common mistakes

  • Tokens in URLs. They end up in logs and browser history. Use the Authorization header only.
  • No audience check. A token for another service would work on yours.
  • Asking for every scope up front. Ask for the minimum, then step up.
  • Reusing client credentials across authorization servers. Credentials belong to the authorization server that issued them.
  • OAuth for stdio servers. Local stdio servers take credentials from their environment instead.

Exercises

Each exercise ends with a value on the last line. Press Check to compare it with the expected answer.

What to Remember

  • A remote MCP server is an OAuth 2.1 resource server. stdio servers use environment credentials instead.
  • 401 + WWW-Authenticate with resource_metadata → protected resource metadata → authorization server metadata.
  • Register with pre-registration or Client ID Metadata Documents. Dynamic Client Registration is deprecated.
  • Sign in with PKCE (S256), the resource parameter, and minimal scopes. Check iss on the redirect.
  • Send Authorization: Bearer on every request. The server checks audience, expiry, and scopes, and answers 403 insufficient_scope for step-up.
  • Never pass tokens through to other APIs.

Next: Performance, Caching and Extensions, making servers fast, cheap, and future-proof.