OAuth 2.1 has one flow at its center: the authorization code flow. Browser opens, user logs in, the authorization server hands the browser an authorization code, the browser sends the code to your app, your app exchanges the code for an access token. Done.
The first time you implement this, there is a quiet question that does not get asked: what stops someone else from intercepting the code and exchanging it themselves?
The answer is supposed to be a secret. The "client secret." A long random string the OAuth provider gave your application when you registered it. When you exchange the code for a token, you also send the secret. The provider checks it matches the one for that client_id, and without the match, the exchange is rejected.
That works neatly. Except for a large class of applications — including every MCP host worth thinking about — the secret is a lie.
The problem with public clients
If your "app" is a server you control, you can keep a secret. The secret lives in environment variables, the server holds it, never sends it anywhere except in the token exchange. That is a confidential client in OAuth-speak.
If your "app" is anything else — a CLI tool, a desktop application, a mobile app, a single-page application, an MCP host spawned on a user's laptop — you cannot keep a secret. The "secret" sits in the binary, the JS bundle, the config file, the source. Anyone who can read those files knows the secret. The OAuth spec calls these public clients, and "public" is exactly the right word: the secret is not.
So you have two unsatisfying choices:
- Pretend the secret is a secret. Hope nobody decompiles your application. (This is what most OAuth-on-mobile implementations did for years, and there is a small graveyard of CVEs to show for it.)
- Do not use a secret at all. Skip the client authentication step.
Option 1 is a lie. Option 2 means the authorization code is the only thing standing between an attacker and a valid token — and authorization codes travel through redirects that are not always private. Custom URI schemes that another app on the same phone can register. Browser history. Log files. That weird intermediate proxy enterprise IT runs. None of those are reliably confidential.
PKCE is the third option. PKCE says: forget the secret. Prove possession instead.
The trick
When your application starts an OAuth flow, it generates a random string. Long, high-entropy. Call it the code_verifier.
Then it hashes the code_verifier with SHA-256, URL-safe base64 encodes the result, and gets a string called the code_challenge.
Your application sends the code_challenge along with the authorization request — ?code_challenge=<...>&code_challenge_method=S256. The OAuth server stores it next to the upcoming authorization code.
Later, when your application exchanges the authorization code for a token, it sends the original code_verifier — the un-hashed string. The server hashes the verifier, checks the result matches the stored challenge, and only then issues the token.
Why does this work? Because if an attacker intercepted the authorization code somewhere along the redirect path, they do not have the verifier. The verifier never traveled. It only goes out at the token-exchange step, which happens over a connection the attacker cannot see. Without the verifier, the intercepted code is a dead letter.
So PKCE replaces "the application holds a permanent secret" with "the application holds a freshly-generated, single-use secret that it never sends until the very last step." The secret is ephemeral. Each OAuth flow generates a new one. Each verifier only works with its specific authorization code, once.
That is the whole protocol. Everything else is bookkeeping.
What it looks like in code
In Node.js, generating a verifier and challenge is fifteen lines:
import { randomBytes, createHash } from "node:crypto";
function base64UrlEncode(buf: Buffer): string {
return buf
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
const codeVerifier = base64UrlEncode(randomBytes(32));
const codeChallenge = base64UrlEncode(
createHash("sha256").update(codeVerifier).digest(),
);In the authorization-request URL, you append:
&code_challenge=<codeChallenge>&code_challenge_method=S256In the token-exchange POST body, you include:
&code_verifier=<codeVerifier>That is PKCE. The whole RFC is forty pages of the consequences of those lines. Most production OAuth libraries — openid-client, jose, the SDKs that ship with Auth0 and Okta — do this for you. Knowing what they are doing is the difference between trusting the library and being able to reach for the protocol when something goes wrong.
Three details that trip people up
S256 vs plain. The spec allows two challenge methods. S256 (SHA-256 of the verifier) is the one you want. plain (the verifier itself, no hash, sent as the challenge) exists for legacy compatibility and is essentially useless — if the attacker can intercept the verifier on the way out, they win. Always use S256. OAuth 2.1 deprecates plain outright.
Verifier length. The spec says 43 to 128 characters. Generate at least 32 random bytes (which gives you 43 base64-url characters). Less and you weaken the entropy in a way that might survive today's compute and not next year's; more is fine but pointless. Most libraries default to 32 bytes, so if you let the library handle it, you are already correct.
Storing the verifier between requests. Your application needs to hold onto the verifier between starting the auth flow and the token exchange — sometimes minutes apart if the user clicks slow, gets distracted, opens a different tab. In a desktop or CLI application, this is just a variable in process memory. In a browser SPA, it is sessionStorage (not localStorage, because different tabs should not share an in-flight OAuth state). In a mobile application, it is process memory; if the OS kills the application between the redirect and the token exchange, the flow has to restart. That is annoying but not insecure.
Why MCP servers care
A remote MCP server — one that uses the Streamable HTTP transport instead of stdio — needs OAuth because the connection between agent and server crosses the network. The agent is the OAuth client.
But the agent is a public client. Claude Desktop is an installable application on the user's machine; its source can be inspected, its config can be read. Cursor is the same. Any agent that runs locally is a public client by definition — there is no "server side" where the secret could safely live.
Without PKCE, a malicious browser extension or a network attacker on the same machine could intercept the authorization code (which travels through the browser, after all) and exchange it for a token. PKCE closes that gap.
This is why the MCP spec mandates OAuth 2.1 + PKCE for remote servers. It is not paranoia. It is acknowledging that the agents using your server are, by their nature, public clients, and a permanent secret was never going to work for them.
If you are building a stdio MCP server — process spawned by the host, talks over stdin/stdout — you do not need OAuth at all. The OS already authenticated the user; the spawned process inherits that trust. PKCE is irrelevant. Local stdio servers are still the right answer for most developer-facing tools, and the transport post is where to confirm whether your project is actually one of those.
What PKCE does not fix
PKCE is a small, focused security primitive. It does exactly one thing: makes intercepted authorization codes useless. It does not:
- Authenticate the user. That is what your IdP's login form does.
- Encrypt the tool-call payloads. TLS does that.
- Stop a stolen access token from being used. Short token TTLs and refresh-token rotation handle that.
- Replace audit logging. PKCE protects the issuance step. What the agent does with the token is a separate problem, and the security post covers it.
The reason it is worth the integration work is that everything else in your auth flow is doing other jobs. PKCE is the only thing standing between "I redirected to the right URL" and "I got the right token from that redirect." It is the small invariant the rest of the system rests on.
This is a good frame for thinking about security primitives in general: each one solves one well-defined problem, and the security of the system is the product of all of them being in place at once. Skip any single one and the chain breaks at that link, regardless of how strong every other link is. PKCE happens to be the link that protects the issuance step. Without it, every other link is doing its job in service of a token that never had a right to exist.
A working flow, end to end
Here is what the full sequence looks like for a remote MCP server. Claude Desktop is the agent in this example, but the shape is the same for any public client.
- User asks Claude Desktop to connect to a new MCP server. Server URL is configured in
claude_desktop_config.json. - Claude Desktop notices it has no token for this server. It generates
code_verifierandcode_challenge. - Claude Desktop opens the user's default browser to the server's
/authorizeendpoint, including thecode_challengeand a redirect URL it controls. - The user authenticates against the server's IdP —
node-oidc-provider, Auth0, Okta, or whichever was chosen. - The IdP redirects the browser to Claude Desktop's redirect URL with an authorization code attached.
- Claude Desktop catches the redirect, extracts the code, and POSTs to the IdP's
/tokenendpoint withgrant_type=authorization_code, thecode, and thecode_verifierit stashed in step 2. - The IdP hashes the verifier, compares to the challenge stored in step 3. Match → issues access + refresh tokens. No match → returns a 400 and the flow dies.
- Claude Desktop stores the access token. From now on, every MCP tool call to your server includes
Authorization: Bearer <access_token>. - When the access token expires (15 minutes is typical), Claude Desktop uses the refresh token to get a new access token from the same
/tokenendpoint, and continues without bothering the user again.
More steps than the old session-cookie login flow. Every step is justified. None of them are theatre. Skip one and you have left a hole — usually a hole only a dedicated attacker would find, but enterprise procurement is paid to assume dedicated attackers exist, which is why "OAuth 2.1 + PKCE" is the line in their security questionnaire and a homemade JWT system is not.
Where this fits
If you arrived here without reading the OAuth 2.1 walkthrough, you may be missing a piece of the picture. PKCE makes the most sense once you understand why the authorization code flow exists in the first place.
If you are still working out whether your MCP server actually needs any of this, the transport post is the upstream decision. Stdio servers do not need PKCE. Streamable HTTP servers do.
And if PKCE has now landed and the rest of MCP security is the next concern, the security post covers the tool-design half — the half that compounds across every transport choice and every auth model.
PKCE is one of the most elegant small protocols in production cryptography. Thirty lines. One trick. Applied carefully. The fact that it is now a hard requirement for remote MCP servers is good news — it raises the floor for everyone, including teams that would not have thought to add it on their own.