import { JsonLd } from '@/components/json-ld'; import { articleJsonLd, breadcrumbJsonLd, pageMetadata } from '@/lib/seo'; import Link from 'next/link'; import { ArticleShell, H2, Note, OL, P, Strong, UL } from '../article-shell'; const PATH = '/guides/mcp-oauth-plain-english'; const TITLE = 'OAuth 2.1 for MCP servers: PKCE, DCR and RFC 8707 in plain English'; const DESCRIPTION = 'The three RFCs behind MCP authorization — PKCE, Dynamic Client Registration and Resource Indicators — explained without jargon: what each one does, the full flow step by step, and what breaks when you skip one.'; export const metadata = pageMetadata({ title: TITLE, description: DESCRIPTION, path: PATH }); export default function Page() { return ( <>

Why MCP needs more than classic OAuth

Classic OAuth assumes you know your clients in advance: you register an app in a dashboard, get a client_id and client_secret, and ship them inside your application. MCP breaks both assumptions. The client is someone's Claude Desktop or Cursor install — you will never pre-register it — and it runs on a desktop where a baked-in secret is not a secret. OAuth 2.1 plus three extension RFCs is how the MCP spec resolves this.

The two roles: Authorization Server and Resource Server

Every OAuth setup splits into two jobs. The Authorization Server (AS){' '} authenticates users and issues tokens. The Resource Server (RS) — your MCP server — accepts requests only when they carry a valid token. They can be the same deployment or separate services; the protocol only cares that the RS can verify what the AS signs, typically via a published JWKS (the AS's public keys).

PKCE (RFC 7636): proof that the token requester started the flow

The authorization-code flow hands the client a one-time code via a browser redirect, and the client exchanges that code for a token. The classic attack is stealing the code in-flight and exchanging it yourself. PKCE closes this: the client invents a random secret (the verifier), sends only its hash (the challenge) when the flow starts, and must present the original verifier at exchange time. A thief who intercepted the code never saw the verifier, so the code is useless to them.

In OAuth 2.1, PKCE is mandatory for every client — the old exemption for "confidential" server-side clients is gone. If your AS treats PKCE as optional, it is not OAuth 2.1.

Dynamic Client Registration (RFC 7591): clients register themselves

When a user adds your MCP server to Claude Desktop, the client calls your AS's registration endpoint at runtime — "here is my name and redirect URI" — and receives a fresh client_id on the spot. No dashboard, no support ticket, no shared credentials between users. DCR is what makes "paste a URL, connect, done" possible: without it, every user of every MCP client would need you to manually provision app credentials.

Resource Indicators (RFC 8707): tokens bound to one server

A user might connect their client to ten different MCP servers. Without{' '} Resource Indicators, a token minted for server A could be replayed against server B — any server you talk to could impersonate you elsewhere. RFC 8707 has the client name the exact server (the resource) when requesting the token, and the AS bakes that audience into the token. Your MCP server then rejects any token whose audience is not itself. One token, one server, no cross-server replay.

The full flow, step by step

  1. The client sends an unauthenticated request to your MCP server and gets a{' '} 401 with a WWW-Authenticate header pointing at the protected-resource metadata — this is how the client discovers your AS.
  2. The client registers itself with the AS via DCR and receives a client_id.
  3. The client generates a PKCE verifier + challenge and opens the browser to the AS's authorize endpoint, naming your server as the resource.
  4. The user signs in and approves; the AS redirects back with a one-time code.
  5. The client exchanges code + PKCE verifier for an access token that is audience-bound to your server.
  6. Every subsequent MCP request carries the token; your server verifies signature, expiry and audience on each call.

What breaks when you skip a piece

  • No protected-resource metadata / wrong 401 — clients can't discover your AS. The symptom: the server "works" with curl but silently fails to install from Claude Desktop. This is the most common failure in the wild.
  • No DCR — the connect flow dead-ends at "unknown client" for anyone but you.
  • No PKCE (or PKCE accepted but not enforced) — the flow appears to work but intercepted authorization codes become exchangeable. Invisible until exploited.
  • No audience check (RFC 8707) — tokens for other servers are accepted by yours, and yours are accepted elsewhere. Also invisible until exploited.
The failure modes split into two families: discovery mistakes are loud (nothing connects), security mistakes are silent (everything connects, including attackers). Test for both — an end-to-end install from a real client proves discovery, but only negative tests (expired token, wrong audience, missing PKCE) prove the security half.

Common implementation mistakes

  • Substring-matching redirect URIs instead of exact-match comparison.
  • Allowing authorization codes to be exchanged more than once.
  • Accepting plain PKCE instead of requiring S256.
  • Verifying token signature and expiry but forgetting the audience claim — RFC 8707 only protects you if the RS actually checks it.
  • Long-lived access tokens as a substitute for refresh tokens — shorter access-token lifetime plus refresh is the OAuth 2.1 posture.

Build it or get it built-in

None of this is exotic, but it is a genuine authorization-server implementation — days of work plus ongoing spec-tracking, and mistakes are security bugs rather than build failures. If you want to own it, the{' '} hosting guide {' '} walks through the deployment options. If you'd rather not: every server generated on BuildMyMCPServer ships behind an OAuth 2.1 AS with PKCE, DCR and Resource Indicators already wired — the flow above is what our{' '} OAuth docs {' '} implement, and the free tier{' '} includes it.

); }