---
title: "Resource Server Does Not Implement OAuth 2.0 Protected Resource Metadata"
description: "Your MCP server returns 401 but carries no resource_metadata pointer, so discovery never starts. The cause and fix in each SDK and gateway."
canonicalUrl: "https://zuplo.com/learn/mcp/errors/401-without-www-authenticate"
pageType: "mcp-error"
errorString: "Resource server does not implement OAuth 2.0 Protected Resource Metadata."
lastVerified: "2026-07-27"
specVersion: "2026-07-28"
---

# `Resource server does not implement OAuth 2.0 Protected Resource Metadata.`

> Your server answers 401 correctly, but the response carries no pointer to protected resource metadata — so the client has nothing to discover the authorization server with, and the OAuth flow never starts.

_MCP Specification 2026-07-28._

## Is this you?

Two requests, no credentials. Read the raw challenge line, then fetch the document it should point at. This separates "the pointer is missing" from "the pointer is there and something later broke".

```bash
# 1. The challenge. Read the WWW-Authenticate line verbatim.
curl -sS -D - -o /dev/null -X POST https://your-server.example.com/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

# 2. The document a client probes when the header omits it.
curl -isS https://your-server.example.com/.well-known/oauth-protected-resource/mcp
```

**This error** — A 401 with no `WWW-Authenticate` at all, or one that stops at `Bearer realm="…"` or `Bearer error="invalid_token"` with no `resource_metadata`, while the second request 404s. If the well-known request answers 401 instead of 200, something in front of the server is intercepting the discovery path too. A 200 whose `resource` is not the URL you dialed is the same failure one step later — RFC 9728 requires the client to discard that document, and both reference SDKs reject any `resource` that is not the same origin and a path prefix of the URL they requested.

**Working** — `HTTP/1.1 401` carrying `WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required", scope="mcp:read", resource_metadata="https://your-server.example.com/.well-known/oauth-protected-resource/mcp"`, and a `200 application/json` from the second request whose `resource` is exactly `https://your-server.example.com/mcp` — the URL a client dials — and whose `authorization_servers` names at least one server. A 401 with no header is also fine as long as that document is served: a conformant client falls back to the well-known probe. If this is what you see, this is not your problem.

**Clients also report this as:**

- `Could not fetch resource metadata`
- `No authorization_servers found in resource metadata <url> - Is this resource metadata configured correctly?`
- `Protected resource <advertised> does not match expected <dialed>`
- `Failed to start OAuth flow for <server>: SDK auth failed: Protected resource <advertised> does not match expected <dialed> (or origin)`

## Fix it

**In short.** Make the 401 carry `resource_metadata="…"` and serve the document it points at. In the MCP Python SDK one setting does both — `resource_server_url` on `AuthSettings`; the TypeScript SDK needs two, `resourceMetadataUrl` on `requireBearerAuth` for the challenge and `mcpAuthMetadataRouter` to mount the document; FastMCP needs the verifier wrapped in `RemoteAuthProvider`. Publish the external URL clients actually dial, host and path included, as that document's `resource` — RFC 9728 requires a client to discard metadata whose `resource` does not match. If your code already sets the header, something in front of the server is replacing the 401 on the way out, and that is the layer to fix.

The symptom is identical across platforms; the cause is not.

### MCP Python SDK

If your server is the MCP Python SDK and you passed `token_verifier=` with an `AuthSettings` whose `resource_server_url` is `None`

1. `resource_server_url` is a required field on `AuthSettings`, but it accepts `None`, and nothing validates that you supplied a URL. `RequireAuthMiddleware` appends `resource_metadata="…"` to the challenge only when its `resource_metadata_url` argument is set, and the server only passes that argument when `settings.auth.resource_server_url` is present. Pass `None` and the 401 goes out as `WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required"` — right status, no pointer.
2. The same setting mounts the document. `create_protected_resource_routes` is registered only when `resource_server_url` is set, so the client's well-known fallback 404s as well and discovery has nowhere left to go.
3. Set `resource_server_url` to the public URL of the MCP endpoint itself, not the origin. `build_resource_metadata_url` inserts the well-known segment between host and path, so `https://api.example.com/mcp` publishes at `https://api.example.com/.well-known/oauth-protected-resource/mcp`.
4. That value also becomes `resource` in the document, and the client checks it against the URL it dialed — same origin, and a path prefix of that URL. Behind a TLS-terminating proxy this has to be the external `https://` URL, or you trade this error for the mismatch variant.

```python
# mcp 2.x, which is a release candidate at the time of writing. On the
# 1.x stable line the class is FastMCP; the AuthSettings fields below are
# the same.
from mcp.server import MCPServer
from mcp.server.auth.settings import AuthSettings
from pydantic import AnyHttpUrl

mcp = MCPServer(
    "Notes",
    token_verifier=MyTokenVerifier(),
    auth=AuthSettings(
        issuer_url=AnyHttpUrl("https://auth.example.com"),
        # Omit this, or pass None: 401 with no resource_metadata, and no
        # well-known route mounted.
        resource_server_url=AnyHttpUrl("https://api.example.com/mcp"),
        required_scopes=["notes:read"],
    ),
)
```

[MCP Python SDK: Authorization](https://github.com/modelcontextprotocol/python-sdk/blob/main/docs/run/authorization.md)

### MCP TypeScript SDK

If your server gates the route with `requireBearerAuth` and omits `resourceMetadataUrl`

1. `resourceMetadataUrl` is optional in `BearerAuthOptions`. The SDK's challenge builder adds `error` and `error_description` unconditionally, `scope` when `requiredScopes` is non-empty, and `resource_metadata` only when that URL is set.
2. Build the URL with `getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/mcp'))` and pass it to `requireBearerAuth`. The Express middleware and the web-standard `fetch` gate share one core, so this is the same fix on Workers, Deno, Bun and Hono.
3. Serve what the challenge points at: `mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl })` mounts `/.well-known/oauth-protected-resource/mcp` plus a mirror of the authorization server metadata. On a `fetch`-handler host use `oauthMetadataResponse` from `@modelcontextprotocol/server`.
4. The same challenge is emitted on `403 insufficient_scope`, so setting `resourceMetadataUrl` once also fixes scope step-up.

```ts
// @modelcontextprotocol/express and /server are the 2.x split packages,
// in beta at the time of writing. On stable @modelcontextprotocol/sdk
// (1.x) the same three exports live under the /server/auth path.
import {
  getOAuthProtectedResourceMetadataUrl,
  mcpAuthMetadataRouter,
  requireBearerAuth
} from '@modelcontextprotocol/express';

const mcpServerUrl = new URL('https://api.example.com/mcp');

const auth = requireBearerAuth({
  verifier,
  requiredScopes: ['mcp'],
  // Omit this and the 401 challenge carries no resource_metadata.
  resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
});

app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpServerUrl }));
app.all('/mcp', auth, (req, res) => void node(req, res, req.body));
```

[MCP TypeScript SDK: Require authorization](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/serving/authorization.md)

### FastMCP

If you pass a bare verifier as FastMCP's `auth=` — `JWTVerifier`, `IntrospectionTokenVerifier`, `StaticTokenVerifier`, `DebugTokenVerifier`

1. This is documented behaviour, not a bug. FastMCP's token-verification docs: "`TokenVerifier` focuses exclusively on token validation without providing OAuth discovery metadata" and "Token verification operates somewhat outside the formal MCP authentication flow, which expects OAuth-style discovery."
2. Wrap the verifier in `RemoteAuthProvider` with `authorization_servers` and `base_url`. That is the piece that adds discovery: "The core discovery endpoint is `/.well-known/oauth-protected-resource`, which tells clients that your server requires OAuth authentication and identifies the authorization servers you trust."
3. Keep the bare verifier only where every client is provisioned with a token out of band — internal services, a fixed set of agents. It works; it just cannot bootstrap a client that has never seen your server, which is what you are hitting.
4. `base_url` is what the published `resource` is derived from, so it has to be the externally reachable URL of the server, not a container-local one.

```python
from fastmcp import FastMCP
from fastmcp.server.auth import RemoteAuthProvider
from fastmcp.server.auth.providers.jwt import JWTVerifier
from pydantic import AnyHttpUrl

token_verifier = JWTVerifier(
    jwks_uri="https://auth.example.com/.well-known/jwks.json",
    issuer="https://auth.example.com",
    audience="mcp-production-api",
)

# auth=token_verifier alone publishes no discovery metadata.
auth = RemoteAuthProvider(
    token_verifier=token_verifier,
    authorization_servers=[AnyHttpUrl("https://auth.example.com")],
    base_url="https://api.example.com",
)

mcp = FastMCP(name="Protected API", auth=auth)
```

[FastMCP: Remote OAuth](https://gofastmcp.com/servers/auth/remote-oauth)

### AWS API Gateway (REST)

If a Lambda or Cognito authorizer on a REST API answers the 401 before your integration runs

1. API Gateway writes that response itself: "If API Gateway fails to process an incoming request, it returns to the client an error response without forwarding the request to the integration backend." Your MCP server never executes, so no header it would have set exists.
2. The response type is `UNAUTHORIZED`, default status 401 — "The gateway response when the custom or Amazon Cognito authorizer failed to authenticate the caller." Customize it and map a literal `WWW-Authenticate` value, single-quoted as parameter mappings require.
3. `ACCESS_DENIED` (403) is the sibling used for authorization failure. Set it too if you emit `insufficient_scope` challenges.
4. Expose the metadata document on a route with no authorizer attached. If `/.well-known/oauth-protected-resource/...` is behind the same authorizer, the client's fallback probe gets a 401 and discovery still fails.
5. Gateway response customization is REST-API only — the AWS reference for `responseParameters` says "Supported only for REST APIs." On an HTTP API you have to emit the challenge from a proxy integration or from a component in front of API Gateway.

```json
"x-amazon-apigateway-gateway-responses": {
  "UNAUTHORIZED": {
    "statusCode": "401",
    "responseParameters": {
      "gatewayresponse.header.WWW-Authenticate": "'Bearer resource_metadata=\"https://api.example.com/.well-known/oauth-protected-resource/mcp\", scope=\"mcp\"'"
    },
    "responseTemplates": {
      "application/json": "{\"error\":\"invalid_token\"}"
    }
  }
}
```

[API Gateway: gateway response types](https://docs.aws.amazon.com/apigateway/latest/developerguide/supported-gateway-response-types.html)

### ingress-nginx

If ingress-nginx fronts the server and 401 is listed in `custom-http-errors`

1. Listing a status code there turns on interception — "Setting at least one code also enables proxy_intercept_errors which are required to process error_page." The client then receives the default backend's response, and "NGINX does not change the response from the custom default backend" — your challenge is gone with the rest of your 401.
2. Remove 401 (and 403, if you use scope challenges) from the ConfigMap `custom-http-errors`, or override it on the MCP ingress with the `nginx.ingress.kubernetes.io/custom-http-errors` annotation, which "will set NGINX proxy-intercept-errors, but only for the NGINX location associated with this ingress." The annotation takes a comma-separated list of codes and its value replaces the global list for that host and path, so list the codes you do want intercepted and leave 401 out. An empty annotation value is not a way to say "none": the parser rejects it as invalid content and the global list still applies.
3. Check `hide-headers` in the same ConfigMap too — it drops named headers from the upstream response before the client sees them.
4. If you must keep custom error pages, the default backend has to emit the challenge itself. NGINX hands it `X-Code` and `X-Original-URI`, and "the custom backend is expected to return the correct HTTP status code instead of 200."

```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: mcp
  annotations:
    # Replaces the global custom-http-errors list for this host and path.
    # 401 and 403 are absent, so they are not intercepted here and the
    # WWW-Authenticate header reaches the client intact.
    nginx.ingress.kubernetes.io/custom-http-errors: "404,503"
```

[ingress-nginx: custom errors](https://kubernetes.github.io/ingress-nginx/user-guide/custom-errors/)

### Azure Container Apps

If Container Apps built-in authentication (Easy Auth) is returning the 401 for you

1. Container Apps Easy Auth with `unauthenticatedClientAction: Return401` answers `www-authenticate: Bearer realm="<app>.<region>.azurecontainerapps.io"` and nothing else — no `resource_metadata`. It also answers `/.well-known/oauth-protected-resource` itself with 401, so the client's fallback probe never reaches your container and both discovery paths are closed.
2. There is no Container Apps setting that fixes this today. A Microsoft collaborator has confirmed the gap on an open issue in the Container Apps repo; the same issue reports that `WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPES`, the App Service setting that does it, appears to be silently ignored on the Container Apps sidecar, and asks Microsoft to confirm whether it is meant to work there at all.
3. App Service and Functions have the documented counterpart: set the `WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPES` app setting to a comma-separated list of your application's scopes and App Service Authentication hosts the protected resource metadata. Microsoft labels PRM support preview.
4. On Container Apps, exclude the MCP route and `/.well-known/oauth-protected-resource` from Easy Auth and emit the challenge and the document from your own code, or move the server to App Service.

```bash
# What Container Apps Easy Auth returns on the discovery path today:
curl -isS https://<app>.<region>.azurecontainerapps.io/.well-known/oauth-protected-resource

# HTTP/2 401
# www-authenticate: Bearer realm="<app>.<region>.azurecontainerapps.io"
#
# No resource_metadata in the challenge, and the document itself is gated,
# so neither discovery mechanism can succeed while Easy Auth owns the route.
```

[microsoft/azure-container-apps#1736](https://github.com/microsoft/azure-container-apps/issues/1736)

### Something else

Whatever component you are running, the fix has one of two shapes. Either make the 401 carry `resource_metadata="…"` pointing at a document you actually serve, or serve `/.well-known/oauth-protected-resource/<path>` and let the client's fallback probe find it — the MCP authorization spec (revision 2025-11-25) accepts either mechanism and requires clients to support both. Then find the last hop that touched the response: run the `curl` above against the server directly, then again through each proxy, ingress or platform gate in front of it, and change the layer where the header disappeared rather than the MCP handler. Whichever component publishes the document, its `resource` value has to be the external URL clients dial — host and path — or RFC 9728 requires them to discard it.

## Why it happens

A client that gets a `401` does not guess where to authenticate. It reads `resource_metadata` out of the `WWW-Authenticate` challenge, fetches that RFC 9728 document, and follows `authorization_servers` to the authorization server; with no pointer it falls back to probing `/.well-known/oauth-protected-resource/<path>` and then the root, and if neither is served, discovery is over — the client reports a metadata or connection error rather than prompting anyone to log in. The pointer is optional in both reference SDKs, and omitting it drops the parameter from the challenge with no warning. The other case is a header your code did set: something in front of the server replaces the 401 before it reaches the client.

### The exchange

The MCP client posts to the server unauthenticated and gets a 401 whose WWW-Authenticate challenge carries no resource_metadata parameter. The client falls back to probing the well-known protected resource metadata path, that returns 404, and discovery ends there — the authorization server is never contacted.

**Participants:** MCP client, MCP server, Authorization server

1. **MCP client → MCP server** — `POST /mcp` — `no Authorization header`
2. **MCP server → MCP client** — `401 Unauthorized` — `Bearer error="invalid_token"` _(response)_
3. **MCP client → MCP server** — `GET well-known fallback` — `/.well-known/oauth-protected-resource/mcp`
4. **MCP server → MCP client** — `404 Not Found` — `no metadata document mounted` _(response; this step fails — the flow stops here)_

Notes:

- **Step 2** — The challenge is a valid 401 and machine-readable, but it carries no resource_metadata parameter, so the client has no metadata URL to follow.
- **Steps 3–4** — With nothing in the header, a conformant client probes the path-based well-known URI and then the root. Neither is served, so discovery ends here and the authorization server is never named.

## What lands on you next

The challenge header is one line of one standard, and it took a day to find. There are five more standards in this flow, and then the questions stop being about connecting at all.

- **Which other discovery documents does a client expect from us?** — `RFC 9728 protected resource metadata` — The challenge points at a metadata document, which points at authorization server metadata, which the client then uses to register and to request scopes. A gateway serves that chain as one unit.
- **The client authenticated. Which of our tools may it call?** — `mcp-capability-filter-inbound` — An authenticated client is not an authorized one. The filter allow-lists capabilities per route and blocks the rest with `MethodNotFound` before the request reaches your server.
- **Which caller is this, next time a request looks wrong?** — `capability_invocation` — Each tool call resolves to a caller, a capability and an outcome with a named reason code, rather than to a status line in an access log.
- **Can this token be replayed against our other MCP route?** — `RFC 8707 resource indicators` — No. A gateway-issued token is bound to one route's canonical resource URI, so a token minted for one route is rejected at another.

All of these attach to one MCP route's `policies.inbound` — the same policy engine on the way in and on the way out. See [Zuplo MCP Gateway](/mcp-gateway).

## Keep reading

- **Similar error** — [`Your MCP server returns a login page or 403 instead of 401`](/learn/mcp/errors/302-redirect-instead-of-401) — Something in front of your MCP server is enforcing authentication built for humans in browsers. Instead of the 401 an MCP client needs, it returns a login page, a redirect to one, or a bare 403 — so the client never learns how to authenticate.
- **Similar error** — [`Dynamic Client Registration not supported`](/learn/mcp/errors/dynamic-client-registration-not-supported) — The MCP client reached your authorization server's metadata and found no way to obtain a client ID — no registration endpoint, no Client ID Metadata Document support, and none configured in the client — so it stopped before the OAuth flow began.
- **Similar error** — [`MCP headers config ignored when server has OAuth discovery`](/learn/mcp/errors/configured-headers-ignored-when-oauth-discovery-present) — The credential is sitting in your client config and never reaches the wire: the client found OAuth discovery metadata on your server first, and switched to the OAuth flow instead of sending the header you set.
- **Error reference** — [every documented MCP error](/learn/mcp/errors)
