Blog

MCP OAuth for AI agents: how both sign-ins work

T. Krause ·

MCP OAuth is how an MCP client, such as claude.ai or Claude Code, gets an access token for a remote MCP server without anyone pasting a key. The MCP authorization spec builds it on OAuth 2.1. The server answers an unauthenticated request with a 401 that points to its metadata. The client finds the authorization server there, registers itself and sends the user to a consent page with PKCE. It then gets back a token that only that MCP server accepts. When the server calls GitHub or Slack for the agent, those tokens come from a second, ordinary OAuth flow per app and per user. The spec forbids passing the first token through to them.

Below, each step of the first flow is shown with the real requests and responses from Prompt TM's MCP endpoint, so you can run them yourself with curl. The spec version quoted is 2026-07-28. After that come the second flow and the cases where an API key is still the better choice.

Two flows, two kinds of token

Flow 1: client to MCP server. The MCP client is the OAuth client, the MCP server is the resource server, and an authorization server issues the token. The person who approves is the one using the client. This is the part the MCP spec defines.

Flow 2: MCP server to each app. The MCP server, or the platform behind it, is the OAuth client of GitHub, Slack or Google. The person who approves is the user whose account the agent acts in. This is ordinary OAuth, run once per app and user, and the MCP spec doesn't change it.

The spec keeps the two apart: "The access token used at the upstream API is a separate token, issued by the upstream authorization server. The MCP server MUST NOT pass through the token it received from the MCP client." If one token worked in both places, any service that received it could use it on the other.

Flow 1, request by request

1. The server answers with a 401 and a pointer

The client calls the MCP endpoint with no credentials. The server refuses and names its metadata document in the WWW-Authenticate header, as RFC 9728 describes:

curl -si -X POST https://www.prompt-tm.com/mcp/mcp_example \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | grep -i www-authenticate
www-authenticate: Bearer resource_metadata="https://www.prompt-tm.com/.well-known/oauth-protected-resource/mcp/mcp_example"

Clients must handle this header. If a server sends none, they try /.well-known/oauth-protected-resource at the endpoint's path and then at the root.

2. Protected resource metadata names the authorization server

curl -s https://www.prompt-tm.com/.well-known/oauth-protected-resource/mcp/mcp_example
{
  "resource": "https://www.prompt-tm.com/mcp/mcp_example",
  "authorization_servers": ["https://www.prompt-tm.com"],
  "bearer_methods_supported": ["header"],
  "scopes_supported": ["mcp"],
  "resource_documentation": "https://www.prompt-tm.com/docs/mcp-clients"
}

resource is the server's canonical URI, which the client sends back later. Every Prompt TM MCP server has its own, so a token can be tied to one server.

3. Authorization server metadata lists the endpoints

The client fetches /.well-known/oauth-authorization-server (RFC 8414), or the OpenID Connect discovery document if that's what the server offers. The fields that matter here:

{
  "issuer": "https://www.prompt-tm.com",
  "authorization_endpoint": "https://www.prompt-tm.com/oauth/authorize",
  "token_endpoint": "https://www.prompt-tm.com/oauth/token",
  "registration_endpoint": "https://www.prompt-tm.com/oauth/register",
  "revocation_endpoint": "https://www.prompt-tm.com/oauth/revoke",
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"],
  "authorization_response_iss_parameter_supported": true,
  "token_endpoint_auth_methods_supported": ["none"]
}

The client must check that issuer matches the address it built the URL from. It must also refuse to go on if code_challenge_methods_supported is missing, because that field is the only way it can tell the server supports PKCE.

4. The client registers

The spec lists three ways to get a client id. In order of preference, they are credentials the client already has for this server, a Client ID Metadata Document (an HTTPS URL that is the client id), and Dynamic Client Registration under RFC 7591. The spec now marks dynamic registration as deprecated and keeps it for compatibility. Prompt TM offers dynamic registration at /oauth/register and doesn't advertise metadata documents yet, so clients register there. Registration is open to anyone, as MCP clients expect, and rate-limited per IP.

The spec says redirect URIs must be HTTPS or localhost. Prompt TM goes beyond that rule and also accepts a native app's own scheme, such as cursor:// or vscode://. A loopback redirect may use any port (RFC 8252), which is what command-line clients need, since they pick a free port at sign-in. Every other redirect must match the registered one exactly.

5. The user approves on the consent page

The client opens the browser at the authorization endpoint with a PKCE code_challenge (method S256) and a resource parameter holding the MCP server's canonical URI (RFC 8707). The spec requires resource in both the authorization and the token request, whether or not the server uses it.

On Prompt TM's consent page you sign in, pick the MCP server (preselected from resource), enter the user id the agent should act as, and allow. The redirect back carries the code, your state and an iss parameter (RFC 9207), which lets the client confirm the code came from the authorization server it expected.

6. The client swaps the code for tokens

The token request sends the code, the redirect_uri and the PKCE code_verifier. Prompt TM hashes the verifier with SHA-256 and compares it to the challenge from step 5, so a stolen code is useless without the verifier. Codes are valid for five minutes and work once. If a used code comes back, Prompt TM revokes everything issued from it.

The response holds an access token valid for one hour and a refresh token valid for 30 days. The spec requires authorization servers to rotate refresh tokens for public clients, so each refresh returns a new pair. If an old refresh token shows up again, someone kept a copy, and Prompt TM ends the whole grant.

7. Every call carries the token in a header

Authorization: Bearer ptmat_...

The spec requires the header on every request and forbids access tokens in the query string. The server must check that the token was issued for it. On Prompt TM a token works only on the MCP server picked at consent. Any other server answers 403 with "This token belongs to another MCP server." Only SHA-256 hashes of tokens are stored. A grant ends when you revoke it on the MCP server's page in the dashboard, when the client calls /oauth/revoke, or when the person who approved it leaves the project.

Flow 2: the agent's accounts in other apps

The MCP token says which server and which user id. It doesn't open anyone's GitHub. For that, each user connects their accounts once, and the platform holds those tokens.

On Prompt TM, an auth config sets how a toolkit signs in. With managed auth, users sign in through Prompt TM's own OAuth app for that toolkit, where one is set up. With your own OAuth app, you register it with the provider, set the redirect URI to https://www.prompt-tm.com/api/v3/auth/callback, and users see your app's name on the provider's consent screen. A connect link sends the user through the provider's sign-in and expires after an hour. The GitHub guide shows the whole sequence with real requests.

From then on, Prompt TM stores the tokens sealed with AES-256-GCM and refreshes any that expire within a minute before a call. If the provider refuses a refresh, it marks the account EXPIRED and sends connected_account.expired to your webhook. The agent only ever sees tool results.

Where API keys still fit

Not every client signs in. Claude Code, Cursor and agents built on an SDK can send a header, so they can use x-api-key with a key you control. For clients that can neither sign in nor send headers, Prompt TM can put a scoped key in the URL. That key opens one MCP server, for the user id you entered when you made it, and nothing else, and you revoke it under Settings → API keys.

Treat a URL with a key in it as a secret. The spec keeps OAuth access tokens out of query strings, and the same caution applies to keys, because URLs end up in server logs and browser history. Prefer OAuth where the client supports it, then a header, and keep the URL key for the rest. The MCP client docs list which client takes which.

A checklist if you run your own MCP server

Taken from the spec's requirements for servers and authorization servers:

  1. Serve protected resource metadata, and tell clients where it is. The spec requires one of two ways: a WWW-Authenticate: Bearer resource_metadata="..." header on every 401, or the document at its well-known URI. Prompt TM does both.
  2. Advertise code_challenge_methods_supported with S256, and require PKCE.
  3. Serve every authorization endpoint over HTTPS, and match redirect URIs exactly against the registered ones.
  4. Accept only tokens issued for your server, and answer invalid or expired tokens with a 401.
  5. Rotate refresh tokens for public clients, and keep access tokens short-lived.
  6. Never forward the client's token to an upstream API. Get a separate token from that API's own authorization server.

To skip building flow 1 and flow 2 yourself, create a free Prompt TM project, connect an account and point your client at the server URL. The Claude Code setup guide covers the client side.