DEV Community

Usama Ejaz
Usama Ejaz

Posted on Originally published at justblogged.com

How OAuth Works for a Remote MCP Server

When you add a remote MCP server to Claude, Cursor or Claude Code, a browser window opens, you log in, and the client is connected. No API key, no config file with a secret in it. A fair amount happens in those few seconds, and most of it is standard OAuth 2.1 with a few pieces the MCP spec adds on top.

This post walks through that flow using a live remote MCP server I run at https://justblogged.com/mcp as the example. Every response below is real output from curl, trimmed to the relevant parts. The rules come from the MCP authorization specification.

Step 1: The unauthenticated request

Some MCP methods are public on this server. initialize and tools/list work without a token, so a client or a curious developer can see what the server does before signing in. Calling a tool that touches blog data is different:

curl -i -X POST https://justblogged.com/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_blogs","arguments":{}}}'
Enter fullscreen mode Exit fullscreen mode
HTTP/2 401
cache-control: no-store
www-authenticate: Bearer realm="OAuth", resource_metadata="https://justblogged.com/.well-known/oauth-protected-resource/mcp", scope="blogs:manage"
Enter fullscreen mode Exit fullscreen mode

The WWW-Authenticate header is the starting point for everything else. It tells the client three things:

  • Bearer: send an access token in the Authorization header.
  • resource_metadata: where to find metadata about this protected resource. This comes from RFC 9728.
  • scope: which scope to ask for. Here there is only one, blogs:manage.

A client that gets this response does not need any server-specific configuration. It has a URL to follow.

Step 2: Protected resource metadata

curl https://justblogged.com/.well-known/oauth-protected-resource/mcp
Enter fullscreen mode Exit fullscreen mode
{
  "resource": "https://justblogged.com/mcp",
  "authorization_servers": ["https://justblogged.com"],
  "scopes_supported": ["blogs:manage"],
  "bearer_methods_supported": ["header"],
}
Enter fullscreen mode Exit fullscreen mode

This document answers "who issues tokens for this server?" The MCP server is the resource server. The authorization server could be a separate service like an identity provider, and authorization_servers is how the client finds it. In this case both live on the same origin.

The path matters. The metadata URL is the well-known prefix plus the path of the MCP endpoint (/mcp), which is how a host serving several protected resources can describe each one separately. The spec says clients should use the URL from WWW-Authenticate when it is present, and fall back to constructing the well-known URI when it is not.

resource is also used later. The client sends it as the resource parameter (RFC 8707, Resource Indicators) in the authorization and token requests, so the token is issued for this specific server and is not meant to be accepted anywhere else.

Step 3: Authorization server metadata

Next, the client fetches the authorization server's metadata (RFC 8414):

curl https://justblogged.com/.well-known/oauth-authorization-server
Enter fullscreen mode Exit fullscreen mode
{
  "issuer": "https://justblogged.com",
  "authorization_endpoint": "https://justblogged.com/oauth/authorize",
  "token_endpoint": "https://justblogged.com/oauth/token",
  "registration_endpoint": "https://justblogged.com/oauth/register",
  "scopes_supported": ["blogs:manage"],
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "none"],
  "code_challenge_methods_supported": ["S256"],
  "authorization_response_iss_parameter_supported": true,
  "client_id_metadata_document_supported": true
}
Enter fullscreen mode Exit fullscreen mode

The fields a client reads here:

  • authorization_endpoint and token_endpoint: where to send the user, and where to exchange the code.
  • code_challenge_methods_supported: ["S256"]: PKCE is supported, with SHA-256. The MCP spec requires clients to check this and refuse to continue if PKCE is not advertised.
  • token_endpoint_auth_methods_supported includes none: public clients, such as a desktop app or CLI that cannot keep a secret, are allowed.
  • registration_endpoint and client_id_metadata_document_supported: the two ways a client can get a client ID. More on that next.

This server does not serve /.well-known/openid-configuration (it returns 404). That is fine. The spec has clients try both OAuth and OpenID Connect discovery URLs, and either one is enough.

Step 4: Getting a client ID

Classic OAuth assumes a developer registers their app with the provider ahead of time. That does not work for MCP, where any client might connect to any server. The spec offers two answers.

Dynamic client registration

With Dynamic Client Registration (RFC 7591), the client registers itself at runtime:

curl -X POST https://justblogged.com/oauth/register \
  -H 'Content-Type: application/json' \
  -d '{
    "client_name": "curl-test",
    "redirect_uris": ["http://127.0.0.1:33418/callback"],
    "grant_types": ["authorization_code", "refresh_token"],
    "response_types": ["code"],
    "token_endpoint_auth_method": "none"
  }'
Enter fullscreen mode Exit fullscreen mode
{
  "client_id": "x9FmJdo4leive3by",
  "redirect_uris": ["http://127.0.0.1:33418/callback"],
  "client_name": "curl-test",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none",
  "client_id_issued_at": 1791646827
}
Enter fullscreen mode Exit fullscreen mode

201 Created, and the client has an ID. The loopback redirect URI is typical for CLI tools, which start a small local server to receive the code. The downside of this approach is that the server ends up with a registration for every client instance, and it knows nothing about who is behind a client_name beyond what the client claims.

Client ID Metadata Documents

The newer option, added in the 2025-11-25 revision of the spec, is a Client ID Metadata Document. Instead of registering, the client uses an HTTPS URL as its client_id. That URL serves a JSON document with the client's name and redirect URIs. The authorization server fetches it when the user arrives, and checks that the redirect URI in the request is listed there.

This ties a client ID to a domain the client's developer controls, and there is no registration table to grow. The metadata advertises client_id_metadata_document_supported: true, so clients that support it can use it, and clients that only know registration still have /oauth/register.

Step 5: Authorization with PKCE

Now the client sends the user to the browser. First it creates a PKCE pair:

verifier=$(openssl rand -base64 48 | tr -d '=+/' | cut -c1-64)
challenge=$(printf '%s' "$verifier" | openssl dgst -sha256 -binary | openssl base64 | tr '+/' '-_' | tr -d '=')
Enter fullscreen mode Exit fullscreen mode

Then it opens a URL like this:

https://justblogged.com/oauth/authorize
  ?response_type=code
  &client_id=x9FmJdo4leive3by
  &redirect_uri=http://127.0.0.1:33418/callback
  &scope=blogs:manage
  &state=<random>
  &code_challenge=<challenge>
  &code_challenge_method=S256
  &resource=https://justblogged.com/mcp
Enter fullscreen mode Exit fullscreen mode

The user logs in, sees a consent screen that says the client can read and manage blog content, settings, domains and themes, and approves. The browser is redirected to the loopback address with a code, the state value, and an iss parameter (the metadata says authorization_response_iss_parameter_supported), which lets the client confirm the response came from the server it started with.

PKCE is what makes this safe for a public client. The code alone is useless. Only the party holding the original verifier can exchange it, so a code intercepted on the way back cannot be redeemed by someone else.

Step 6: The token

The client exchanges the code at the token endpoint:

curl -X POST https://justblogged.com/oauth/token \
  -d grant_type=authorization_code \
  -d code=<code> \
  -d redirect_uri=http://127.0.0.1:33418/callback \
  -d client_id=x9FmJdo4leive3by \
  -d code_verifier="$verifier" \
  -d resource=https://justblogged.com/mcp
Enter fullscreen mode Exit fullscreen mode

The response contains an access token and, because refresh_token is a supported grant, a refresh token. From then on every MCP request carries:

Authorization: Bearer <access_token>
Enter fullscreen mode Exit fullscreen mode

The metadata lists header as the only bearer method, which matches the spec: tokens go in the header, never in the URL. When the access token expires, the client uses the refresh token to get a new one without sending the user back to the browser. Clients like Claude Code store and refresh tokens on their own, which is why you sign in once.

If a token is wrong or unknown, the server rejects it. For example, a token request with a client ID that does not exist returns:

{"error":"invalid_client","error_description":"Client not found"}
Enter fullscreen mode Exit fullscreen mode

The scope, and its tradeoff

blogs:manage is the only scope. A token with it can do everything the signed-in account can do: list and read posts, publish, delete, change domains and edit themes, across every blog the account has access to. Normal account permissions still apply, so a member who cannot delete a blog in the dashboard cannot delete it over MCP either.

A single scope keeps the consent screen honest and simple, and it means every client gets the same capabilities. The cost is that there is no way to hand an agent read-only access, or write access to one blog and not the others. If you want an assistant to audit your archive, it gets the same token it would need to delete it.

Finer scopes, such as separate read and write, or scopes per blog, would fix that. For now, the practical move is to use clients you trust, keep work in draft, and ask the assistant to show destructive changes before running them.

Summary of the flow

  1. Client calls the MCP endpoint and gets 401 with WWW-Authenticate.
  2. Client reads the protected resource metadata from resource_metadata.
  3. Client reads the authorization server metadata and checks for S256.
  4. Client gets a client ID through registration or a metadata document URL.
  5. User approves in the browser; the client receives a code.
  6. Client exchanges the code plus PKCE verifier for tokens, bound to the resource.
  7. Every MCP request carries Authorization: Bearer.

That is the whole handshake. The live responses above are from the JustBlogged MCP endpoint I used while building it.

Top comments (0)