Secure your remote MCP server with OAuth 2.1 and PKCE, the protocol's standard authentication mechanism. Register your server as an OAuth provider, implement the authorization and token endpoints, validate access tokens on each request, and configure clients to authenticate. Local stdio servers skip auth since the host process controls access.
Adding Authentication to Your MCP Server
MCP uses OAuth 2.1 with PKCE (Proof Key for Code Exchange) as its standard authentication mechanism for remote servers. When an MCP client connects to your server over HTTP, it discovers your OAuth endpoints through the /.well-known/oauth-authorization-server metadata endpoint, then performs the authorization flow to get an access token.
Local stdio servers do not need authentication because the host process (like Claude Desktop) manages access. But any server exposed over HTTP must implement auth to prevent unauthorized access. This tutorial walks through the full implementation: OAuth server metadata, authorization flow, token issuance, and request validation.
Prerequisites
- A working MCP server using Streamable HTTP transport (not stdio)
- Understanding of OAuth 2.0 concepts (authorization codes, access tokens, refresh tokens)
- Node.js 18+ with a web framework like Express for HTTP endpoints
- HTTPS configured for your server (required for OAuth in production)
Step-by-step guide
Understand when authentication is required
Understand when authentication is required
MCP authentication only applies to remote servers using HTTP-based transports (Streamable HTTP or the deprecated SSE transport). Local stdio servers inherit the security context of the host process — Claude Desktop, Cursor, or whichever application launched the server process. If your server is only used locally via stdio, you can skip authentication entirely. For remote servers, auth is critical to prevent unauthorized tool calls and data access.
1// Stdio transport — NO auth needed2import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";3const transport = new StdioServerTransport();4await server.connect(transport);56// HTTP transport — auth IS needed7// Clients connect over the network and must authenticateExpected result: You understand that auth is needed only for HTTP-exposed MCP servers.
Expose OAuth server metadata
Expose OAuth server metadata
MCP clients discover your auth configuration by fetching /.well-known/oauth-authorization-server from your server. This JSON document advertises your authorization endpoint, token endpoint, supported grant types, and PKCE requirements. Implement this endpoint on your HTTP server so clients know how to authenticate.
1// TypeScript — Express route for OAuth metadata2import express from "express";34const app = express();5const SERVER_URL = "https://mcp.example.com";67app.get("/.well-known/oauth-authorization-server", (req, res) => {8 res.json({9 issuer: SERVER_URL,10 authorization_endpoint: `${SERVER_URL}/oauth/authorize`,11 token_endpoint: `${SERVER_URL}/oauth/token`,12 registration_endpoint: `${SERVER_URL}/oauth/register`,13 response_types_supported: ["code"],14 grant_types_supported: ["authorization_code", "refresh_token"],15 token_endpoint_auth_methods_supported: ["none"],16 code_challenge_methods_supported: ["S256"],17 });18});Expected result: MCP clients can discover your auth endpoints by fetching the well-known metadata URL.
Implement the authorization and token endpoints
Implement the authorization and token endpoints
The authorization endpoint handles the user consent flow — the client redirects the user here to grant access. After consent, redirect back to the client with an authorization code. The token endpoint exchanges codes for access tokens and handles refresh token grants. Store authorization codes and tokens securely with expiration times.
1// TypeScript — simplified auth endpoints2import crypto from "crypto";34const authCodes = new Map<string, { clientId: string; codeChallenge: string; userId: string; expiresAt: number }>();5const accessTokens = new Map<string, { clientId: string; userId: string; expiresAt: number }>();67// Authorization endpoint8app.get("/oauth/authorize", (req, res) => {9 const { client_id, redirect_uri, code_challenge, code_challenge_method, state } = req.query;1011 if (code_challenge_method !== "S256") {12 return res.status(400).json({ error: "S256 code challenge required" });13 }1415 // In production: show consent page, authenticate user16 // For simplicity, auto-approve:17 const code = crypto.randomUUID();18 authCodes.set(code, {19 clientId: client_id as string,20 codeChallenge: code_challenge as string,21 userId: "user-1",22 expiresAt: Date.now() + 600_000, // 10 minutes23 });2425 const redirectUrl = new URL(redirect_uri as string);26 redirectUrl.searchParams.set("code", code);27 if (state) redirectUrl.searchParams.set("state", state as string);28 res.redirect(redirectUrl.toString());29});3031// Token endpoint32app.post("/oauth/token", express.urlencoded({ extended: false }), (req, res) => {33 const { grant_type, code, code_verifier } = req.body;3435 if (grant_type !== "authorization_code") {36 return res.status(400).json({ error: "unsupported_grant_type" });37 }3839 const authCode = authCodes.get(code);40 if (!authCode || authCode.expiresAt < Date.now()) {41 return res.status(400).json({ error: "invalid_grant" });42 }4344 // Verify PKCE code challenge45 const hash = crypto.createHash("sha256").update(code_verifier).digest("base64url");46 if (hash !== authCode.codeChallenge) {47 return res.status(400).json({ error: "invalid_grant" });48 }4950 authCodes.delete(code);51 const accessToken = crypto.randomUUID();52 accessTokens.set(accessToken, {53 clientId: authCode.clientId,54 userId: authCode.userId,55 expiresAt: Date.now() + 3600_000, // 1 hour56 });5758 res.json({59 access_token: accessToken,60 token_type: "Bearer",61 expires_in: 3600,62 });63});Expected result: Clients can complete the OAuth authorization flow and receive access tokens.
Validate tokens on MCP requests
Validate tokens on MCP requests
Add middleware to your HTTP server that extracts the Bearer token from the Authorization header and validates it before processing any MCP request. Reject requests with missing, expired, or invalid tokens. Pass the authenticated user context to your MCP tool handlers so they can apply per-user authorization logic.
1// TypeScript — auth middleware2function authMiddleware(req: express.Request, res: express.Response, next: express.NextFunction) {3 const authHeader = req.headers.authorization;4 if (!authHeader?.startsWith("Bearer ")) {5 return res.status(401).json({ error: "Bearer token required" });6 }78 const token = authHeader.slice(7);9 const tokenData = accessTokens.get(token);1011 if (!tokenData || tokenData.expiresAt < Date.now()) {12 accessTokens.delete(token);13 return res.status(401).json({ error: "Token expired or invalid" });14 }1516 // Attach user context for tool handlers17 (req as any).userId = tokenData.userId;18 next();19}2021// Apply to MCP endpoint22app.post("/mcp", authMiddleware, (req, res) => {23 // Process MCP JSON-RPC request with authenticated user context24});Expected result: Unauthenticated requests are rejected with 401. Authenticated requests proceed with user context available.
Configure clients to authenticate with your server
Configure clients to authenticate with your server
MCP clients handle the OAuth flow automatically when they discover the well-known metadata. For Claude Desktop, add your server to the config with the HTTP URL. Claude will detect the auth requirement, open a browser for user consent, and manage token refresh. For teams deploying authenticated MCP servers to production, RapidDev can help set up the OAuth infrastructure, user management, and token rotation.
1// claude_desktop_config.json2{3 "mcpServers": {4 "my-remote-server": {5 "url": "https://mcp.example.com/mcp"6 }7 }8}910// The client will:11// 1. Fetch /.well-known/oauth-authorization-server12// 2. Generate PKCE code_verifier and code_challenge13// 3. Open browser to authorization_endpoint14// 4. Exchange auth code for access token at token_endpoint15// 5. Include Bearer token in all subsequent MCP requestsExpected result: Claude Desktop or other MCP clients can connect to your server after completing the OAuth flow.
Complete working example
1import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";2import { z } from "zod";3import express from "express";4import crypto from "crypto";56const SERVER_URL = process.env.SERVER_URL || "https://mcp.example.com";7const app = express();89// In-memory stores (use a database in production)10const authCodes = new Map<string, {11 clientId: string; codeChallenge: string;12 userId: string; expiresAt: number;13}>();14const tokens = new Map<string, {15 userId: string; expiresAt: number;16}>();1718// OAuth metadata19app.get("/.well-known/oauth-authorization-server", (_req, res) => {20 res.json({21 issuer: SERVER_URL,22 authorization_endpoint: `${SERVER_URL}/oauth/authorize`,23 token_endpoint: `${SERVER_URL}/oauth/token`,24 response_types_supported: ["code"],25 grant_types_supported: ["authorization_code"],26 code_challenge_methods_supported: ["S256"],27 token_endpoint_auth_methods_supported: ["none"],28 });29});3031// Authorization endpoint (simplified)32app.get("/oauth/authorize", (req, res) => {33 const { redirect_uri, code_challenge, state } = req.query;34 const code = crypto.randomUUID();35 authCodes.set(code, {36 clientId: "mcp-client",37 codeChallenge: code_challenge as string,38 userId: "user-1",39 expiresAt: Date.now() + 600_000,40 });41 const url = new URL(redirect_uri as string);42 url.searchParams.set("code", code);43 if (state) url.searchParams.set("state", state as string);44 res.redirect(url.toString());45});4647// Token endpoint48app.post("/oauth/token", express.urlencoded({ extended: false }), (req, res) => {49 const { code, code_verifier } = req.body;50 const authCode = authCodes.get(code);51 if (!authCode || authCode.expiresAt < Date.now()) {52 return res.status(400).json({ error: "invalid_grant" });53 }54 const hash = crypto.createHash("sha256").update(code_verifier).digest("base64url");55 if (hash !== authCode.codeChallenge) {56 return res.status(400).json({ error: "invalid_grant" });57 }58 authCodes.delete(code);59 const accessToken = crypto.randomUUID();60 tokens.set(accessToken, { userId: authCode.userId, expiresAt: Date.now() + 3600_000 });61 res.json({ access_token: accessToken, token_type: "Bearer", expires_in: 3600 });62});6364// Auth middleware65function requireAuth(req: express.Request, res: express.Response, next: express.NextFunction) {66 const bearer = req.headers.authorization?.slice(7);67 const t = bearer ? tokens.get(bearer) : undefined;68 if (!t || t.expiresAt < Date.now()) return res.status(401).end();69 (req as any).userId = t.userId;70 next();71}7273app.post("/mcp", requireAuth, (req, res) => {74 // Wire up MCP server to handle JSON-RPC over HTTP75 console.error(`Authenticated request from user: ${(req as any).userId}`);76});7778app.listen(3000, () => console.error("Auth MCP server on port 3000"));Common mistakes when adding OAuth authentication to an MCP server
Why it's a problem: Adding authentication to a local stdio server
How to avoid: Stdio servers run as a child process of the host application. The host controls access. Auth is only needed for HTTP-exposed servers.
Why it's a problem: Not requiring PKCE
How to avoid: OAuth 2.1 mandates PKCE for public clients. MCP clients are public clients. Always require S256 code_challenge_method.
Why it's a problem: Storing tokens in memory only
How to avoid: In-memory token stores are lost on restart. Use a database or Redis for production deployments.
Why it's a problem: Not setting token expiration
How to avoid: Always set expires_in on tokens and reject expired tokens. Implement refresh tokens for long-lived sessions.
Why it's a problem: Running OAuth over HTTP instead of HTTPS
How to avoid: OAuth requires HTTPS in production. Tokens sent over HTTP can be intercepted. Use HTTPS for all auth endpoints.
Best practices
- Use an established OAuth library or service (Auth0, Keycloak) instead of building from scratch
- Always require PKCE with S256 for all MCP OAuth flows
- Set short access token lifetimes (1 hour) and implement refresh tokens
- Store tokens in a database with proper encryption, not in memory
- Validate tokens on every MCP request, not just the initial connection
- Pass authenticated user context to tool handlers for per-user authorization
- Use HTTPS for all auth endpoints in production
- Log authentication events (login, token refresh, failures) to stderr for security monitoring
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I'm building a remote MCP server that needs OAuth 2.1 with PKCE authentication. Show me how to implement the well-known metadata endpoint, authorization endpoint, token endpoint, and request validation middleware in TypeScript with Express.
Add OAuth 2.1 authentication to my MCP server at [URL]. Implement the /.well-known/oauth-authorization-server metadata endpoint, PKCE authorization flow, token issuance, and Bearer token validation middleware.
Frequently asked questions
Do stdio MCP servers need authentication?
No. Stdio servers run as child processes of the host application (Claude Desktop, Cursor). The host controls which servers are launched and has full access. Authentication is only needed for HTTP-based remote servers.
Why does MCP use OAuth 2.1 instead of API keys?
OAuth 2.1 with PKCE provides a standard, secure flow that works across different clients without sharing secrets. API keys would require each client to securely store the key, which browser-based and desktop clients cannot reliably do.
Can I use my existing OAuth provider (Auth0, Okta) with MCP?
Yes. Configure your MCP server's well-known metadata to point to your OAuth provider's authorization and token endpoints. The MCP client will follow the standard OAuth flow with your provider.
How does the client handle token refresh?
MCP clients should implement standard OAuth refresh token flow. When an access token expires (401 response), the client uses the refresh token to get a new access token without requiring user interaction.
Can I restrict which tools a user can access?
MCP does not have built-in per-tool authorization. Implement authorization checks inside each tool handler using the authenticated user context. Return isError: true for unauthorized tool calls.
Is there a simpler auth option for internal servers?
For internal servers on a private network, you could use a reverse proxy with basic auth or mutual TLS. However, OAuth 2.1 is the standard MCP mechanism and is recommended for interoperability. For production auth architecture, the RapidDev team can help evaluate the right approach for your infrastructure.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation