DEV Community

Cover image for MCP on OAuth 2.1: one URL and a consent click, no API key
Panth Patel
Panth Patel

Posted on Originally published at panth.vardayinitech.in

MCP on OAuth 2.1: one URL and a consent click, no API key

Adding my tracker to Claude takes one URL and one click on a consent screen. Nobody makes a key, copies it, or pastes it anywhere.

The constraint: an MCP client that a person uses every day should not hold a long-lived secret the person had to handle by hand, and the person should be able to see and cut off what it can reach.

I'm Panth, and I lead the software team at Oizom. This is from ticket-tracker, my open-source board. Its decisions page lists the choices that are expensive to reverse, each with the alternative that was turned down. This is D9.

The decision

Chosen OAuth 2.1 with PKCE, dynamic client registration, and protected-resource metadata, as the MCP authorization spec expects
Rejected Pasting an API key into the MCP client
Why It is the only way a person adds the server with one URL and a consent click, in Claude and other clients. The grant is per person, can be revoked in Account › Connected apps, and every change it makes is attributed via MCP.

API keys did not go away. They still work on /mcp for headless setups, where nobody is there to click Allow. Claude Code takes a token in a header, and so do the orchestrators that run my agents. OAuth is for the case where a person is sitting in front of the client.

What the client does, with nobody pasting anything

The whole flow is discovery. The client starts knowing one URL, and every other address comes from a document the server publishes.

1. POST /mcp, no token
   ← 401  WWW-Authenticate: Bearer resource_metadata=".../.well-known/oauth-protected-resource/mcp"
2. GET  /.well-known/oauth-protected-resource/mcp        (RFC 9728)
   ← { authorization_servers: [base], scopes_supported: [...] }
3. GET  /.well-known/oauth-authorization-server          (RFC 8414)
   ← authorize, token, register and revoke endpoints; PKCE S256 only
4. POST /oauth/register                                  (RFC 7591)
   ← client_id
5. GET  /oauth/authorize?...&code_challenge=...  → our consent screen
6. person picks boards, presses Allow  → redirect with ?code=
7. POST /oauth/token  code + code_verifier  ← access + refresh tokens
Enter fullscreen mode Exit fullscreen mode

Step 1 carries the design. The 401 is not "your token is bad". It is "go and sign in, and here is where to start". A client that has never heard of this server can follow the header all the way to a working token.

Some clients probe OIDC discovery first, so /.well-known/openid-configuration answers with the same document.

The consent screen is where scope gets decided

The screen names the app asking, lists the permissions it wants in plain words ("Read tickets", "Post, edit, pin and react in threads"), and asks which boards it may act on: every board, or the ones you tick. Nothing is granted until you press Allow.

The server checks the ticked boards against the boards the person can actually read. A board you are not on is an error, not a silent drop.

What the grant gives back shapes the MCP server itself. A tool the grant's scopes do not allow is not registered at all, so it is missing from tools/list, and every handler checks again anyway. The model never sees a tool it would only get a 403 from.

Token lifetimes

Token Lives Rules
Authorization code 60 s Single use, bound to the client, the redirect URI and the PKCE S256 challenge
Access token 1 h
Refresh token 30 days Rotated on every use

Tokens are stored only as their SHA-256. The grant is a document under the person (users/{uid}/oauthGrants/{grantId}), and it is the revocation switch: revoking deletes it and every token issued under it. Revoking in Connected apps stops the app on its next call, including sessions already open.

A refresh may narrow scopes, never widen them. Asking for a scope the grant does not hold fails with invalid_scope.

The rule that catches a stolen refresh token

Rotation alone does not help if nobody notices the old token coming back. So a used refresh token is kept, marked with when it was used, until it expires. If it is presented again, that is two parties holding the same token: the real client and someone else. The server revokes the whole grant.

// A client that refreshes twice at once, or retries after losing the response,
// presents the same token seconds apart: that is not theft.
const retry = row.usedAt !== undefined && opts.now - row.usedAt <= REFRESH_RETRY_GRACE_MS;
if (row.usedAt !== undefined && !retry) {
  // THEFT DETECTION: an already-rotated refresh token came back.
  reused = { uid: row.uid, grantId: row.grantId };
  return null;
}
Enter fullscreen mode Exit fullscreen mode

The grace window is 60 seconds. Without it, a client that fires two refreshes in parallel, or loses the response and retries, would lock its own user out. With it, a retry gets a fresh pair and a replay an hour later kills the grant with "Refresh token reuse detected — the app must be connected again".

The small checks that keep it from becoming an open door

Dynamic registration means anyone can register a client, so the edges matter:

  • Registration is rate-limited per IP address: 20 a minute, 500 a day.
  • Redirect URIs must be https, or http on localhost, 127.0.0.1 or [::1] only, or a private-use scheme such as cursor:// or vscode://. javascript:, data:, file:, vbscript: and blob: are refused, and so is any URI with a fragment.
  • A bad client or redirect URI is shown as an error page, never redirected to. Redirecting there would make the server an open redirector. Any other error goes back to the client as ?error= the way RFC 6749 says.
  • Only two grant types: authorization_code and refresh_token.

What it cost

One more surface to own. An API key is one table and one header check. This is an authorization server with eight endpoints, a consent screen in the app, and a Connected apps page. My own review notes had a row for it: the MCP flow pointed at a consent page that did not exist yet. That was fixed by building the page at /oauth/consent, after which the grant shows in Connected apps.

For an MCP server people add to Claude by hand, I would pay it again. The person never touches a secret, sees exactly which boards they handed over, and has one Revoke button.

If you run an MCP server, which do your users hold today: an OAuth grant or a key they pasted?

Top comments (0)