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.
- A customer's AI app connects. The server answers
401with a link to its protected resource metadata. - The metadata names Brightgrid's authorization server.
- The app sends the customer to Brightgrid's sign-in page. The customer signs in and approves "read your orders".
- The app receives a token that is valid only for the help-desk server, with the scope
orders:read. - Later the customer asks to cancel an order. The server answers
403 insufficient_scope, asking fororders: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:
- What changes when a server is remote, hosted, or multi-tenant.
- The HTTP endpoint rules.
- The OAuth 2.1 flow, step by step.
- How client registration works, including Client ID Metadata Documents.
- How servers check tokens, audiences, and scopes.
- 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
localhostduring development. - Check the
Originheader 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:
- Pre-registration: the client already has a client id for this authorization server.
- 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 withclient_id,client_name, andredirect_uris. The authorization server fetches it. This is the recommended way when client and server have never met. Servers advertise it withclient_id_metadata_document_supported. - 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_verifierstays with the client. Only its SHA-256 hash, thecode_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
Authorizationheader 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-Authenticatewithresource_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
resourceparameter, and minimal scopes. Checkisson the redirect. - Send
Authorization: Beareron 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.