When an MCP client connects to a protected server for the first time, it has no token and no idea where to get one. The MCP authorization specification solves this with a two-step discovery handshake: your server answers the first unauthenticated request with a 401 that points at a metadata document, and that document tells the client which authorization server to talk to. Both halves are defined by RFC 9728, OAuth 2.0 Protected Resource Metadata, and the MCP authorization specification makes implementing it mandatory for protected servers. It is also the step most hand-built servers skip, which is why their setup instructions begin with "paste this URL into your client config".
This tutorial walks through implementing the handshake on an HTTP MCP server. The examples use TypeScript and Express, but nothing here is framework-specific.
What the client expects
The flow, from the client's side:
- It calls your MCP endpoint with no token and gets a
401whoseWWW-Authenticateheader names aresource_metadataURL. - It fetches that URL, reads the metadata, and picks an authorization server from
authorization_servers. - It fetches the authorization server's own metadata (RFC 8414), registers or uses a client ID, and runs the OAuth 2.1 authorization code flow with PKCE.
- It returns with a token bound to your server and retries the original request.
Your server owns steps 1 and 2. Get them right and any conformant client can onboard itself.
Publish the metadata document
Choose the canonical resource URI
The resource value in the metadata is the identifier tokens will be bound to, so decide it deliberately: scheme and host, plus the path if one server among several lives under the same host. Use lowercase, no fragment, no trailing slash. https://mcp.example.com/mcp is a good canonical form. Whatever you choose here must match what your token validation later expects as the audience, exactly.
Serve the well-known path
RFC 9728 puts the document at /.well-known/oauth-protected-resource, suffixed with the resource path if your server is not at the host root. For a server at https://mcp.example.com/mcp, that means /.well-known/oauth-protected-resource/mcp:
import express from "express";
const app = express();
const RESOURCE = "https://mcp.example.com/mcp";
const AUTH_SERVER = "https://auth.example.com";
app.get("/.well-known/oauth-protected-resource/mcp", (_req, res) => {
res.json({
resource: RESOURCE,
authorization_servers: [AUTH_SERVER],
scopes_supported: ["mcp:read", "mcp:write"],
bearer_methods_supported: ["header"],
});
});
The document is public by design: serve it unauthenticated, over HTTPS, with a sensible cache lifetime.
Point clients at it from the 401
The WWW-Authenticate header
Every response your MCP endpoint returns for a missing or invalid token must be a 401 carrying a WWW-Authenticate header with the resource_metadata parameter:
function unauthorized(res: express.Response, error?: string) {
const params = [
`resource_metadata="${RESOURCE_METADATA_URL}"`,
error ? `error="${error}"` : null,
].filter(Boolean);
res
.status(401)
.set("WWW-Authenticate", `Bearer ${params.join(", ")}`)
.json({ error: error ?? "unauthorized" });
}
app.post("/mcp", (req, res) => {
const auth = req.get("authorization");
if (!auth?.startsWith("Bearer ")) {
return unauthorized(res);
}
// ... validate the token, then handle the MCP request
});
Two details matter more than they look. First, the header goes on every unauthenticated response, not only the first one; clients re-run discovery whenever they are told to. Second, expired and malformed tokens get the same shape with error="invalid_token", so a client knows to refresh rather than re-register.
Field by field
What belongs in the document, and how strict each field is under RFC 9728 and the MCP specification:
| Field | Requirement | Notes |
|---|---|---|
resource | Required | The canonical resource URI; must match the audience your token validation enforces |
authorization_servers | Required for MCP | Issuer identifiers, each of which must publish its own RFC 8414 metadata |
scopes_supported | Recommended | The scopes clients may request; keep it to what tools actually use |
bearer_methods_supported | Optional | MCP tokens travel in the Authorization header, so ["header"] |
jwks_uri | Optional | Only if your resource publishes keys; token signing keys belong to the authorization server |
Resist the urge to publish every scope your product has. A short list of scopes that map to your tool surface is easier to review and limits the blast radius of a leaked token.
Test it
Prove the handshake works before pointing a real client at it:
# 1. The 401 points somewhere
curl -si -X POST https://mcp.example.com/mcp | grep -i www-authenticate
# 2. The metadata parses and names an authorization server
curl -s https://mcp.example.com/.well-known/oauth-protected-resource/mcp | jq .
# 3. The named authorization server publishes its own metadata
curl -s https://auth.example.com/.well-known/oauth-authorization-server | jq .issuer
If step 3 fails, clients will fail with an error that looks like your server's fault, so check it whenever the identity provider configuration changes.
Where this fits
Discovery is the front door of the MCP authorization flow, and it is the first thing we check when a server reaches us before a customer review: a server that cannot tell clients where its authorization server lives usually has manual configuration, and manual configuration is where the API keys hide. The next job after discovery is validating the tokens that come back, audience first, which is its own tutorial.
The primary sources are short and worth reading end to end: RFC 9728 for the metadata document, RFC 8414 for the authorization server side, and the MCP authorization specification for how the pieces compose. If you would rather have an independent read on your whole authorization surface, that is what our MCP security audit is for.