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":{}}}'
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"
The WWW-Authenticate header is the starting point for everything else. It tells the client three things:
-
Bearer: send an access token in theAuthorizationheader. -
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
{
"resource": "https://justblogged.com/mcp",
"authorization_servers": ["https://justblogged.com"],
"scopes_supported": ["blogs:manage"],
"bearer_methods_supported": ["header"],
}
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
{
"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
}
The fields a client reads here:
-
authorization_endpointandtoken_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_supportedincludesnone: public clients, such as a desktop app or CLI that cannot keep a secret, are allowed. -
registration_endpointandclient_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"
}'
{
"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
}
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 '=')
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
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
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>
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"}
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
- Client calls the MCP endpoint and gets
401withWWW-Authenticate. - Client reads the protected resource metadata from
resource_metadata. - Client reads the authorization server metadata and checks for
S256. - Client gets a client ID through registration or a metadata document URL.
- User approves in the browser; the client receives a code.
- Client exchanges the code plus PKCE verifier for tokens, bound to the resource.
- 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)