MP-301h · Module 1
Authorization Code Flow Internals
4 min read
The authorization code flow is OAuth 2.0's most secure grant type, and it is the only flow MCP specifies for remote servers. The full sequence has six steps: (1) the client generates a random state parameter and redirects the user to the authorization endpoint, (2) the user authenticates with the authorization server, (3) the user reviews and grants the requested scopes, (4) the authorization server redirects back to the client with a short-lived authorization code, (5) the client exchanges the code for an access token at the token endpoint, (6) the client uses the access token in the Authorization header of MCP requests. The state parameter prevents CSRF — if the state in the redirect does not match what the client sent, the response is forged.
The authorization code is intentionally short-lived (typically 30-60 seconds) and single-use. Once exchanged for tokens, it is invalidated. If an attacker intercepts the code, they must exchange it before the legitimate client does — and the authorization server should detect the duplicate exchange and revoke all tokens issued for that code. This is why the code travels through the browser's URL bar (front channel) but the token exchange happens server-to-server (back channel). The code can be intercepted; the tokens cannot, because they never touch the browser.
MCP clients are often native applications or CLI tools, not web applications. This changes the redirect URI story. Web apps redirect to https://app.example.com/callback. Native apps use loopback redirects (http://127.0.0.1:PORT/callback) or custom URI schemes (myapp://callback). The MCP spec recommends loopback redirects for CLI-based clients because they work without registering a custom scheme. The client starts a temporary HTTP server on a random port, uses that port in the redirect URI, receives the authorization code on the callback, and shuts down the temporary server.
// Authorization code flow for MCP CLI client
import http from "node:http";
import crypto from "node:crypto";
import { URL } from "node:url";
async function authorize(authEndpoint: string, tokenEndpoint: string, clientId: string) {
const state = crypto.randomUUID();
const port = 9876 + Math.floor(Math.random() * 100);
const redirectUri = `http://127.0.0.1:${port}/callback`;
// Build authorization URL
const authUrl = new URL(authEndpoint);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("client_id", clientId);
authUrl.searchParams.set("redirect_uri", redirectUri);
authUrl.searchParams.set("state", state);
authUrl.searchParams.set("scope", "tools:execute resources:read");
console.log(`Open in browser: ${authUrl.toString()}`);
// Start temporary callback server
const code = await new Promise<string>((resolve, reject) => {
const server = http.createServer((req, res) => {
const params = new URL(req.url!, `http://127.0.0.1:${port}`).searchParams;
if (params.get("state") !== state) {
res.writeHead(400).end("State mismatch — possible CSRF");
reject(new Error("State mismatch"));
} else {
res.writeHead(200).end("Authorization complete. Close this tab.");
resolve(params.get("code")!);
}
server.close();
});
server.listen(port);
setTimeout(() => { server.close(); reject(new Error("Timeout")); }, 120_000);
});
// Exchange code for tokens (back channel)
const tokenRes = await fetch(tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
client_id: clientId,
}),
});
return tokenRes.json();
}