HomeJournalThis post

MCP Authorization Metadata Without Guesswork

Validate one MCP challenge-to-token discovery transcript and reject mixed-resource, ambiguous-issuer, and metadata-origin failures.

JP
JP Casabianca
AI Engineer and Product Designer · full-stack delivery · Bogotá

MCP authorization metadata should lead a client through declared identities, never hostname guesses. Follow the challenge, protected resource, issuer, and endpoints as one rejectable chain.

MCP authorization metadata starts at the resource

MCP authorization metadata gives a client a declared path from a protected MCP resource to its authorization server. The client begins with the resource it attempted to access and a challenge that names protected-resource metadata; it does not derive an issuer by deleting path segments, assuming a shared hostname, or following an undocumented login link.

The versioned MCP authorization specification connects OAuth protected-resource metadata, authorization-server discovery, and resource indicators. Pin the specification revision your implementation follows because a living SDK or example may change while a deployed client still depends on an older contract.

The bundled Node program parses frozen educational JSON only. It performs no dynamic registration, authorization redirect, token exchange, network request, or hosted interoperability test; its value is a stepwise allow-or-deny transcript for identity comparisons that are easy to overlook.

MCP authorization metadata discovery is a chain of signed expectations even when the documents themselves are public. Begin with the challenged resource, carry that canonical identifier forward, and reject any later document that quietly substitutes a sibling host, path, issuer, or audience.

Challenge-to-token discovery chainA sequence connects the protected resource challenge to exact metadata URLs, issuer checks, and the token request.clientresourceprotected metadataissuer401 + resource_metadata URLGET exact well-known documentGET declared issuer metadatatoken request includes resource
Challenge-to-token discovery chain
A sequence connects the protected resource challenge to exact metadata URLs, issuer checks, and the token request.
  1. Client receives a 401 challenge naming the metadata URL.
  2. Protected-resource metadata repeats the canonical resource and lists one authorization server.
  3. Authorization-server metadata repeats the exact issuer and declares endpoints.
  4. Client sends the resource indicator with authorization and token requests.
Figure 1: Each hop is declared and compared; none is inferred from a nearby hostname.

Parse the challenge without expanding trust

A 401 response can direct the client to protected-resource metadata, but that pointer is untrusted input until syntax and policy checks pass. Require HTTPS outside explicitly local development, cap length, reject credentials and fragments, resolve according to the documented challenge form, and decide whether cross-origin metadata is allowed before fetching anything.

MCP authorization metadata should be retrieved with redirect handling that preserves the original trust decision. Log each redirect without bearer credentials, limit hop count, and reject a final origin or scheme that violates policy; a convenient open redirect on the resource host must not turn into authorization-server selection.

Cache the document according to HTTP semantics, while retaining the resource, metadata URL, retrieval time, status, content type, and body digest. A cache hit is not proof of continued authorization, so invalidation and expiry remain separate from validating the document’s identity fields.

The WWW-Authenticate challenge points at protected resource metadata; it does not grant permission to invent an authorization server from the API hostname. That separation lets one resource declare several supported issuers while preventing a client from turning naming convention into an authority decision.

Validate protected-resource identity exactly

RFC 9728 defines OAuth protected-resource metadata and the relationship between its resource identifier, well-known locations, and authorization servers. Compare the document’s resource value with the canonical resource intended by the request; do not accept a sibling path or origin merely because it shares an operator.

Canonicalization should follow the identifier contract narrowly. Generic URL cleanup can change meaning, so decide how trailing slash, default port, case, percent encoding, and path normalization are handled, then test exact accepted and rejected pairs rather than applying an improvisational “close enough” function.

MCP authorization metadata that names several authorization servers requires an explicit selection rule or user choice. The teaching fixture permits exactly one and rejects ambiguity, which is intentionally stricter than claiming every production resource must have a single server.

MCP authorization server discovery is successful only when resource metadata explicitly names the issuer path the client follows. Cache the resolved chain with its source URLs and validation result, because a token error later is otherwise indistinguishable from a discovery document that changed between requests.

Resolve authorization-server metadata

The protected-resource document names an authorization-server identity, not just a token endpoint. Retrieve its declared metadata using the applicable well-known construction, cap and parse the response, and keep the requested issuer URL beside the returned issuer value for an equality check.

RFC 8414 requires clients to validate the issuer identifier in authorization-server metadata against the issuer used to obtain it. This blocks a substituted document from replacing the authorization and token endpoints while appearing syntactically complete.

MCP authorization metadata discovery should fail on an absent issuer, duplicate security-critical fields, invalid URL, unsupported scheme, or endpoint origin that violates local policy. A denial must identify the checkpoint instead of falling through to a guessed conventional path.

Normalize identifiers before comparison, but do not normalize away security boundaries. Scheme and host casing have defined behavior; ports, paths, trailing segments, and percent-encoded values may remain significant, so the transcript should preserve both the received string and the canonical comparison value.

Metadata identity graphDistinct nodes separate MCP resource identity, protected-resource document, issuer, endpoints, client, and audience.resourcehttps://apiprotected-resourcemetadataissuer identityclienttoken
Metadata identity graph
Distinct nodes separate MCP resource identity, protected-resource document, issuer, endpoints, client, and audience.
Resource
API identifier and intended audience
Protected metadata
Document whose resource field must match
Issuer
Authorization server identity, not merely its token endpoint
Token
Credential validated for the resource
Figure 2: Identity nodes remain separate even when one organization hosts every URL.

Keep resource, issuer, and endpoints distinct

The MCP resource is the protected API audience; the issuer identifies the authorization server; authorization and token endpoints are operational URLs published by that issuer. They may share an origin, but collapsing them into one string makes audience substitution and endpoint drift difficult to detect.

Store each as a typed field and compare only relationships the specifications require or policy adds. For example, an endpoint origin rule can be a deliberate deployment restriction, yet it should be labeled local policy rather than presented as a universal OAuth requirement.

After discovery, validate the MCP token audience against the resource that initiated the chain. Successful issuer discovery cannot prove a token is intended for the resource, and a correct audience cannot repair metadata obtained from an untrusted substitution.

OAuth protected resource metadata answers which authorization servers a resource accepts and which resource identity a token should target. Authorization server metadata then supplies endpoints for that issuer; swapping those roles can make a syntactically valid document authorize the wrong system.

Threat-model server-side request forgery before enabling arbitrary metadata retrieval. Resolve DNS under an egress policy, reject loopback and private ranges where inappropriate, recheck after redirects, bound response time and size, and prevent a discovered URL from inheriting ambient cloud credentials. These protections are deployment policy around the standards chain; the local artifact cannot exercise DNS rebinding, network segmentation, or proxy behavior.

Send resource indicators through the flow

Carry the canonical resource value into authorization and token requests where the MCP contract requires it. Bind the value to the pending authorization transaction beside state, PKCE verifier, redirect URI, issuer, and metadata digests, then compare it again when handling the redirect and token response.

If the client supports OAuth Rich Authorization Requests, keep structured authorization details separate from the resource indicator. One identifies the target service while the other can describe narrower requested rights; neither should be inferred from an access token after the flow has already committed.

MCP authorization metadata therefore participates in transaction integrity, not just configuration bootstrap. A client that rediscovers different endpoints mid-flow should stop or require a controlled restart rather than send a code or verifier into a newly selected chain.

A well-known URL is a retrieval rule, not a trust shortcut. Enforce HTTPS, bounded redirects, response-size limits, content-type expectations, and a strict origin policy before parsing, then validate the parsed issuer and resource fields against the identities already carried in the chain.

Runnable artifact — This validates frozen educational documents and does not claim a full OAuth client, dynamic registration implementation, or hosted MCP interoperability test.

import assert from "node:assert/strict";

const version = "MCP authorization 2025-11-25 fixture";
const resource = "https://api.example/mcp";
const protectedUrl = "https://api.example/.well-known/oauth-protected-resource";
const issuer = "https://auth.example";
const authorizationUrl = issuer + "/.well-known/oauth-authorization-server";
const baseDocuments = Object.freeze({
  [protectedUrl]: Object.freeze({ resource, authorization_servers: Object.freeze([issuer]) }),
  [authorizationUrl]: Object.freeze({ issuer, authorization_endpoint: issuer + "/authorize", token_endpoint: issuer + "/token" }),
});
const canonical = (value) => { const url = new URL(value); url.hash = ""; return url.href.replace(/\/$/, ""); };
function fetchFixture(url, network) {
  const seen = new Set();
  let current = url;
  for (let hop = 0; hop < 4; hop++) {
    if (seen.has(current)) return { ok: false, code: "redirect_loop", url: current };
    seen.add(current);
    const response = network[current];
    if (!response) return { ok: false, code: "metadata_missing", url: current };
    if (response.redirect) {
      const next = new URL(response.redirect, current).href;
      if (new URL(next).origin !== new URL(current).origin) return { ok: false, code: "redirect_origin_changed", url: next };
      current = next;
      continue;
    }
    return { ok: true, url: current, body: response.body || response };
  }
  return { ok: false, code: "too_many_redirects", url: current };
}
function inspect(input) {
  const steps = [];
  const deny = (code, detail) => ({ decision: "deny", code, detail, steps });
  try {
    const challengeUrl = new URL(input.challenge.resource_metadata).href;
    steps.push({ checkpoint: "challenge_url", value: challengeUrl });
    if (new URL(challengeUrl).protocol !== "https:") return deny("metadata_not_https", challengeUrl);
    const protectedFetch = fetchFixture(challengeUrl, input.network);
    if (!protectedFetch.ok) return deny(protectedFetch.code, protectedFetch.url);
    steps.push({ checkpoint: "protected_metadata_fetch", value: protectedFetch.url });
    const protectedMetadata = protectedFetch.body;
    if (canonical(protectedMetadata.resource) !== canonical(input.request.resource)) return deny("resource_identity_mismatch", protectedMetadata.resource);
    steps.push({ checkpoint: "resource_identity", value: canonical(protectedMetadata.resource) });
    if (!Array.isArray(protectedMetadata.authorization_servers) || protectedMetadata.authorization_servers.length !== 1) return deny("ambiguous_authorization_servers", String(protectedMetadata.authorization_servers?.length || 0));
    const declaredIssuer = canonical(protectedMetadata.authorization_servers[0]);
    if (new URL(declaredIssuer).protocol !== "https:") return deny("issuer_not_https", declaredIssuer);
    const wellKnown = declaredIssuer + "/.well-known/oauth-authorization-server";
    const authorizationFetch = fetchFixture(wellKnown, input.network);
    if (!authorizationFetch.ok) return deny(authorizationFetch.code, authorizationFetch.url);
    steps.push({ checkpoint: "authorization_metadata_fetch", value: authorizationFetch.url });
    const metadata = authorizationFetch.body;
    if (canonical(metadata.issuer) !== declaredIssuer) return deny("issuer_mismatch", metadata.issuer);
    steps.push({ checkpoint: "issuer_identity", value: declaredIssuer });
    for (const field of ["authorization_endpoint", "token_endpoint"]) {
      const endpoint = new URL(metadata[field]);
      if (endpoint.protocol !== "https:" || endpoint.origin !== new URL(declaredIssuer).origin) return deny("endpoint_origin_mismatch", field + ":" + endpoint.href);
      steps.push({ checkpoint: field, value: endpoint.href });
    }
    if (canonical(input.token.audience) !== canonical(input.request.resource)) return deny("token_audience_mismatch", input.token.audience);
    steps.push({ checkpoint: "token_audience", value: canonical(input.token.audience) });
    return { decision: "allow", code: "metadata_chain_valid", resource: canonical(input.request.resource), issuer: declaredIssuer, steps };
  } catch (error) { return deny("invalid_url", error.message); }
}
const make = () => ({ challenge: { resource_metadata: protectedUrl }, request: { resource }, token: { audience: resource }, network: structuredClone(baseDocuments) });
const cases = [];
const add = (name, mutate, expected) => { const fixture = make(); mutate?.(fixture); const result = inspect(fixture); assert.equal(result.code, expected, name); cases.push({ name, expected, ...result }); };
add("valid", null, "metadata_chain_valid");
add("missing-protected-metadata", (x) => delete x.network[protectedUrl], "metadata_missing");
add("redirect-origin", (x) => x.network[protectedUrl] = { redirect: "https://evil.example/meta" }, "redirect_origin_changed");
add("issuer-mismatch", (x) => x.network[authorizationUrl].issuer = "https://evil.example", "issuer_mismatch");
add("duplicate-issuer", (x) => x.network[protectedUrl].authorization_servers.push("https://backup.example"), "ambiguous_authorization_servers");
add("metadata-http", (x) => x.challenge.resource_metadata = "http://api.example/meta", "metadata_not_https");
add("endpoint-origin", (x) => x.network[authorizationUrl].token_endpoint = "https://evil.example/token", "endpoint_origin_mismatch");
add("audience-mismatch", (x) => x.token.audience = "https://api.example/other", "token_audience_mismatch");
add("resource-mismatch", (x) => x.request.resource = "https://api.example/other", "resource_identity_mismatch");
console.log(JSON.stringify({ fixtureVersion: version, networkFetchesExecuted: cases.reduce((sum, entry) => sum + entry.steps.filter((step) => step.checkpoint.endsWith("_fetch")).length, 0), cases }, null, 2));
console.log("PASS: pinned discovery fetch chain and eight named denials execute");

Constrain endpoint and redirect behavior

Authorization endpoints normally receive the browser redirect, while token endpoints receive a back-channel request containing sensitive material. Validate schemes, forbid embedded credentials, apply egress rules, avoid forwarding authorization headers across redirects, and set response-size plus timeout limits before connecting to discovered URLs.

The lab’s same-origin endpoint rule is an explicit teaching policy and appears as such in its receipt. Real deployments may permit endpoint origins that differ from the issuer when the metadata contract and risk review allow it, so make this choice configurable but never silent.

If an agent tool token needs sender constraint, bind it with DPoP after the discovery chain is trusted. DPoP does not tell the client which issuer to trust; it protects a different boundary by associating a token or request with a proof key.

Duplicate issuers need an explicit product policy. The teaching client rejects ambiguity instead of picking the first array element, and its denial receipt names the conflicting values so the resource owner can repair configuration rather than asking users to retry authentication.

Give every mutation a denial code

Give every executed mutation a denial code and assert its exact first checkpoint so a permissive change cannot hide behind a later generic failure. A production conformance suite should extend this bounded atlas with invalid-JSON, oversize-document, and stale-transaction tests; those three are recommendations here, not implemented artifact cases.

MCP authorization metadata tests should include valid but unusual URLs and Unicode or percent-encoding cases selected from the actual parser contract. The goal is not to invent alternate OAuth rules; it is to prove that URL parsing, canonicalization, and identity comparison have one reviewable implementation.

The runnable transcript executes one valid chain plus eight named denials: missing protected metadata, redirect-origin change, issuer mismatch, duplicate issuer, non-HTTPS metadata, endpoint-origin mismatch, audience mismatch, and resource mismatch. Because all documents are local constants, a pass demonstrates deterministic validation logic only, while live conformance still needs a controlled MCP server, authorization server, browser redirect, and token-validation suite.

MCP authorization metadata should fail closed at the first mismatched checkpoint while retaining prior valid steps. That transcript is more useful than a generic unauthorized error: it reveals whether the challenge, resource document, issuer document, endpoint origin, or requested audience broke the chain.

Cache metadata with identity and expiry

Cache entries should key on the canonical protected resource and metadata URL, preserve issuer and endpoint digests, and expire according to response policy plus a local maximum. On refresh, compare security-critical identity fields before replacing the active entry, because an unexpected issuer change is an event rather than routine freshness.

Applications also need an emergency invalidation path for compromised or misconfigured metadata. Coordinate it with in-flight authorization transactions so a removed endpoint is not silently reused from browser state, worker memory, or a second cache layer.

Operational logs can retain URLs, digests, status, timing, decision codes, and specification version without storing access tokens or authorization codes. Limit query-string logging because endpoint URLs can still carry sensitive transaction data in a poorly behaved implementation.

The frozen fixtures never fetch the public internet and never exchange a credential. They teach document resolution and identity validation only; dynamic client registration, PKCE, token validation, consent, refresh, and hosted interoperability remain separate layers with their own threat models.

Authorization discovery failure atlasSix broken tiles identify missing metadata, issuer substitution, duplicate servers, redirects, origins, and audience mismatches.missing documentissuer mismatchduplicate serverorigin swapredirect driftaudience mismatch
Authorization discovery failure atlas
Six broken tiles identify missing metadata, issuer substitution, duplicate servers, redirects, origins, and audience mismatches.
Named denials
MutationCheckpoint
Missing metadatachallenge resolution
Issuer mismatchRFC 8414 issuer equality
Origin substitutionendpoint policy
Audience mismatchresource validation
Figure 3: A denial names the exact comparison that failed, making discovery auditable.

Ship discovery as an inspectable chain

A release checklist should name the MCP resource, challenge form, protected-metadata URL policy, RFC 9728 comparisons, authorization-server selection, RFC 8414 issuer equality, endpoint restrictions, redirect behavior, resource-indicator propagation, cache lifetime, denial codes, and token audience validation. Version that checklist with the client.

Run the local discovery artifact and inspect the ordered transcript before connecting a test deployment. Then perform a sandbox flow with OAuth PKCE, verify redirects and resource values, and capture sanitized evidence from both sides; never substitute the deterministic fixture for hosted interoperability.

Revisit the implementation when the MCP specification, relevant RFC errata, discovery guidance, or SDK behavior changes. The durable design principle is simple: every authority in the chain must be declared, retrieved under bounded policy, and compared to the identity that led the client there.

Review MCP authorization metadata after a new MCP revision, OAuth metadata erratum, discovery recommendation, or SDK behavior change. A three-document map works for social distribution, but every derivative must link back to the full denial cases so the attractive happy path does not become the whole security story.

Client registration is another explicit boundary. Static client metadata, dynamic registration, and preconfigured authorization servers have different trust and lifecycle requirements, none of which follows automatically from a valid protected-resource document. Record registration identity and redirect URIs beside the discovered issuer, and require a fresh security review before adding a discovery mode that can create clients at runtime.