🔥 Agencies & studios Run unlimited client servers for a flat $99/mo — zero per-seat or bandwidth tax. Railyard for agencies →
MCP authorization explained: OAuth 2.1, PKCE, client registration and protected resource metadata ← Blog Agency playbooks • 5 min read

MCP authorization explained: OAuth 2.1, PKCE, client registration and protected resource metadata

Almokhtar Bekkour

Almokhtar Bekkour, Founder, Systems Architect

August 11, 2026 · 5 min read

The first time you add a remote MCP server to Claude, a browser tab opens, you click Approve, and the tools appear. No API key gets copied anywhere. That convenience is a full OAuth 2.1 authorization code flow with PKCE, client registration and two discovery documents, compressed into one click.

This post walks through every request in that flow, using Railyard’s MCP server as the concrete example, and explains what each piece protects against.

Why not just paste an API key?

You can. Railyard accepts its API tokens as a Bearer header, which is handy for CI and for clients that don’t do OAuth. But pasted keys have three problems when the client is an AI assistant:

  1. The key lives in the client’s config, often in plain text, often synced across machines.
  2. The person pasting it picks the scope, and nothing stops a viewer from pasting a key an admin created.
  3. Nobody records which client got which key, so revoking one agent’s access means guessing.

OAuth fixes all three. The client never sees your password. You approve a specific client in your own browser session. The server issues a token bound to that client, that team and a scope your role allows.

Request 1: The challenge

A client that has never connected sends its first MCP request with no credentials. The server refuses it and says where to learn more:

POST /api/mcp HTTP/2
content-type: application/json

HTTP/2 401
www-authenticate: Bearer resource_metadata="https://app.railyard.run/.well-known/oauth-protected-resource"

That resource_metadata parameter is defined by RFC 9728 (OAuth 2.0 Protected Resource Metadata). It means the client doesn’t need to know anything about Railyard in advance. The MCP URL alone is enough.

Request 2: Protected resource metadata

GET /.well-known/oauth-protected-resource

{ "resource": "https://app.railyard.run/api/mcp",
  "authorization_servers": ["https://app.railyard.run"] }

Two facts: the canonical identifier of the thing being protected, and which authorization server issues tokens for it. On a self-hosted Railyard both point at your own domain.

Request 3: Authorization server metadata

GET /.well-known/oauth-authorization-server

{
  "issuer": "https://app.railyard.run",
  "authorization_endpoint": "https://app.railyard.run/oauth/authorize",
  "token_endpoint": "https://app.railyard.run/oauth/token",
  "registration_endpoint": "https://app.railyard.run/oauth/register",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none"],
  "scopes_supported": ["read", "deploy", "write"]
}

This is RFC 8414. A few choices in it are deliberate:

  • Only S256. The plain PKCE method gives no protection when the code is intercepted, so it isn’t offered.
  • Only the authorization code grant. No implicit flow, no password grant, no client credentials. A token always traces back to a person clicking Approve.
  • none for client authentication. MCP clients are public clients. A desktop app or a CLI can’t keep a client secret, so pretending it can adds nothing. PKCE does that job instead.

Request 4: Client registration

The client registers itself (RFC 7591, Dynamic Client Registration):

POST /oauth/register
content-type: application/json

{ "client_name": "Claude", "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"] }
{ "client_id": "ray_…", "client_name": "Claude",
  "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
  "token_endpoint_auth_method": "none", "grant_types": ["authorization_code"], "response_types": ["code"] }

Redirect URIs must be https, or a loopback address for local tools like Claude Code that catch the callback on localhost. Anything else is rejected, and registration is rate limited per IP. Later, the authorize step only accepts a redirect URI that exactly matches one registered for that client, so a stolen client_id can’t send codes somewhere new.

The latest MCP revision also describes Client ID Metadata Documents, where a client is identified by a URL it controls instead of registering. Dynamic registration remains the path today’s clients use.

Request 5: Authorization, with PKCE

The client generates a random code_verifier, hashes it with SHA-256 into a code_challenge, and opens your browser at:

https://app.railyard.run/oauth/authorize
  ?response_type=code
  &client_id=ray_…
  &redirect_uri=https://claude.ai/api/mcp/auth_callback
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256
  &state=af0ifjsldkj

You sign in to Railyard if you aren’t already, then see a consent page: which client is asking, a team picker, and an access level. When you approve, Railyard records a grant (client, you, the team, the scope, the challenge) and redirects back with a code:

https://claude.ai/api/mcp/auth_callback?code=…&state=af0ifjsldkj

The code is good for ten minutes and can be used exactly once.

Where the scope gets capped

The access choice is read, deploy or write, but what you’re allowed to grant depends on your role on the team you picked:

Your roleHighest scope you can grant
Viewerread
Deployerdeploy
Admin or ownerwrite

If a viewer submits write, the grant is issued as read. The cap is also re-checked on every later API call against your role at that moment. A connection you made as an admin quietly drops to read if you’re later made a viewer, without anyone having to find and revoke it. If your membership is limited to certain apps, so is every token you grant.

Request 6: Exchanging the code

POST /oauth/token
grant_type=authorization_code&code=…&client_id=ray_…
  &redirect_uri=https://claude.ai/api/mcp/auth_callback
  &code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

The server checks four things together: the code exists and hasn’t expired or been used, it was issued to this client_id, the redirect_uri matches the one from the authorize step, and SHA256(code_verifier) equals the stored challenge. Only then does it answer:

{ "access_token": "rly_…", "token_type": "Bearer", "scope": "deploy" }

The response carries Cache-Control: no-store, so no proxy or browser keeps a copy.

What PKCE actually prevents

Picture an attacker who can read the redirect: a malicious browser extension, a leaky log, another app registered for the same custom scheme on a phone. They get the code. Without PKCE, they could exchange it themselves. With PKCE, the exchange needs the code_verifier, which never left the legitimate client, and can’t be reversed from the challenge. The intercepted code is useless.

Request 7 onward: Using the token

Every MCP request now carries it:

POST /api/mcp
authorization: Bearer rly_…
content-type: application/json

{ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }

Railyard stores only a SHA-256 digest of the token, never the token itself. The connection appears under Settings → Access & tokens with the client’s name, its scope, who created it and when it was last used. Revoking it there cuts the agent off on its very next request.

Common questions

Does the token expire? Not on a timer. It stays valid until you revoke it, or until your membership changes in a way that narrows it. The trade-off is fewer surprise sign-outs mid-task. Revoking from the dashboard is immediate.

Can a client ask for more access later? Only by going through consent again, and the new grant is capped the same way.

What does the agent see if it hits a permission wall? A plain tool error naming the scope it would need, for example “This API token can’t run command — create a write token.” The model can explain that to you instead of retrying blindly.

Do I have to use OAuth? No. For CI or a client without OAuth support, create an API token under Settings → Access & tokens and pass it as Authorization: Bearer rly_…. The same scopes and role caps apply.

Connect it

In Claude, open Customize → Connectors, search for Railyard, and choose Connect. In Claude Code, add the server and run /mcp to authenticate:

claude mcp add --transport http railyard https://app.railyard.run/api/mcp

The full flow above happens in the few seconds between clicking Connect and seeing the tools. How an MCP server for deployments works picks up from there, at the first tool call.

Sign in once, scoped to your role.

Connect Claude to Railyard through OAuth. Pick read, deploy or write on the consent page, and revoke the connection any time.

Free plan, no card, and 7 days of Pro for every new team. One flat price per team.

Theme
Colors