# Remote MCP and Authorization

Course: [Model Context Protocol](https://www.devtutorials.ai/learn/mcp) · Section: Enterprise MCP
Source: https://www.devtutorials.ai/learn/mcp/enterprise/remote-mcp-and-authorization

> 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 <code>/secure/mcp</code>. 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.

## 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."

```js runnable title="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:

```text
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.

```js runnable title="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:

```text
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.

```js runnable title="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:

```text
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
```

```js runnable title="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:

```text
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.

```js runnable title="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:

```text
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 <code>orders:read</code>, 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.
