# How to add OAuth authentication to an MCP server

- Tool: MCP
- Difficulty: Advanced
- Time required: 45-60 min
- Compatibility: MCP TypeScript SDK 1.x with Streamable HTTP transport, OAuth 2.1 compliant
- Last updated: March 2026

## TL;DR

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.

## Before you start

- 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

### 1. 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.

```
// Stdio transport — NO auth needed
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const transport = new StdioServerTransport();
await server.connect(transport);

// HTTP transport — auth IS needed
// Clients connect over the network and must authenticate
```

**Expected result:** You understand that auth is needed only for HTTP-exposed MCP servers.

### 2. 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.

```
// TypeScript — Express route for OAuth metadata
import express from "express";

const app = express();
const SERVER_URL = "https://mcp.example.com";

app.get("/.well-known/oauth-authorization-server", (req, res) => {
  res.json({
    issuer: SERVER_URL,
    authorization_endpoint: `${SERVER_URL}/oauth/authorize`,
    token_endpoint: `${SERVER_URL}/oauth/token`,
    registration_endpoint: `${SERVER_URL}/oauth/register`,
    response_types_supported: ["code"],
    grant_types_supported: ["authorization_code", "refresh_token"],
    token_endpoint_auth_methods_supported: ["none"],
    code_challenge_methods_supported: ["S256"],
  });
});
```

**Expected result:** MCP clients can discover your auth endpoints by fetching the well-known metadata URL.

### 3. 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.

```
// TypeScript — simplified auth endpoints
import crypto from "crypto";

const authCodes = new Map<string, { clientId: string; codeChallenge: string; userId: string; expiresAt: number }>();
const accessTokens = new Map<string, { clientId: string; userId: string; expiresAt: number }>();

// Authorization endpoint
app.get("/oauth/authorize", (req, res) => {
  const { client_id, redirect_uri, code_challenge, code_challenge_method, state } = req.query;

  if (code_challenge_method !== "S256") {
    return res.status(400).json({ error: "S256 code challenge required" });
  }

  // In production: show consent page, authenticate user
  // For simplicity, auto-approve:
  const code = crypto.randomUUID();
  authCodes.set(code, {
    clientId: client_id as string,
    codeChallenge: code_challenge as string,
    userId: "user-1",
    expiresAt: Date.now() + 600_000, // 10 minutes
  });

  const redirectUrl = new URL(redirect_uri as string);
  redirectUrl.searchParams.set("code", code);
  if (state) redirectUrl.searchParams.set("state", state as string);
  res.redirect(redirectUrl.toString());
});

// Token endpoint
app.post("/oauth/token", express.urlencoded({ extended: false }), (req, res) => {
  const { grant_type, code, code_verifier } = req.body;

  if (grant_type !== "authorization_code") {
    return res.status(400).json({ error: "unsupported_grant_type" });
  }

  const authCode = authCodes.get(code);
  if (!authCode || authCode.expiresAt < Date.now()) {
    return res.status(400).json({ error: "invalid_grant" });
  }

  // Verify PKCE code challenge
  const hash = crypto.createHash("sha256").update(code_verifier).digest("base64url");
  if (hash !== authCode.codeChallenge) {
    return res.status(400).json({ error: "invalid_grant" });
  }

  authCodes.delete(code);
  const accessToken = crypto.randomUUID();
  accessTokens.set(accessToken, {
    clientId: authCode.clientId,
    userId: authCode.userId,
    expiresAt: Date.now() + 3600_000, // 1 hour
  });

  res.json({
    access_token: accessToken,
    token_type: "Bearer",
    expires_in: 3600,
  });
});
```

**Expected result:** Clients can complete the OAuth authorization flow and receive access tokens.

### 4. 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.

```
// TypeScript — auth middleware
function authMiddleware(req: express.Request, res: express.Response, next: express.NextFunction) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith("Bearer ")) {
    return res.status(401).json({ error: "Bearer token required" });
  }

  const token = authHeader.slice(7);
  const tokenData = accessTokens.get(token);

  if (!tokenData || tokenData.expiresAt < Date.now()) {
    accessTokens.delete(token);
    return res.status(401).json({ error: "Token expired or invalid" });
  }

  // Attach user context for tool handlers
  (req as any).userId = tokenData.userId;
  next();
}

// Apply to MCP endpoint
app.post("/mcp", authMiddleware, (req, res) => {
  // Process MCP JSON-RPC request with authenticated user context
});
```

**Expected result:** Unauthenticated requests are rejected with 401. Authenticated requests proceed with user context available.

### 5. 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.

```
// claude_desktop_config.json
{
  "mcpServers": {
    "my-remote-server": {
      "url": "https://mcp.example.com/mcp"
    }
  }
}

// The client will:
// 1. Fetch /.well-known/oauth-authorization-server
// 2. Generate PKCE code_verifier and code_challenge
// 3. Open browser to authorization_endpoint
// 4. Exchange auth code for access token at token_endpoint
// 5. Include Bearer token in all subsequent MCP requests
```

**Expected result:** Claude Desktop or other MCP clients can connect to your server after completing the OAuth flow.

## Complete code example

File: `src/auth-server.ts`

```typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import express from "express";
import crypto from "crypto";

const SERVER_URL = process.env.SERVER_URL || "https://mcp.example.com";
const app = express();

// In-memory stores (use a database in production)
const authCodes = new Map<string, {
  clientId: string; codeChallenge: string;
  userId: string; expiresAt: number;
}>();
const tokens = new Map<string, {
  userId: string; expiresAt: number;
}>();

// OAuth metadata
app.get("/.well-known/oauth-authorization-server", (_req, res) => {
  res.json({
    issuer: SERVER_URL,
    authorization_endpoint: `${SERVER_URL}/oauth/authorize`,
    token_endpoint: `${SERVER_URL}/oauth/token`,
    response_types_supported: ["code"],
    grant_types_supported: ["authorization_code"],
    code_challenge_methods_supported: ["S256"],
    token_endpoint_auth_methods_supported: ["none"],
  });
});

// Authorization endpoint (simplified)
app.get("/oauth/authorize", (req, res) => {
  const { redirect_uri, code_challenge, state } = req.query;
  const code = crypto.randomUUID();
  authCodes.set(code, {
    clientId: "mcp-client",
    codeChallenge: code_challenge as string,
    userId: "user-1",
    expiresAt: Date.now() + 600_000,
  });
  const url = new URL(redirect_uri as string);
  url.searchParams.set("code", code);
  if (state) url.searchParams.set("state", state as string);
  res.redirect(url.toString());
});

// Token endpoint
app.post("/oauth/token", express.urlencoded({ extended: false }), (req, res) => {
  const { code, code_verifier } = req.body;
  const authCode = authCodes.get(code);
  if (!authCode || authCode.expiresAt < Date.now()) {
    return res.status(400).json({ error: "invalid_grant" });
  }
  const hash = crypto.createHash("sha256").update(code_verifier).digest("base64url");
  if (hash !== authCode.codeChallenge) {
    return res.status(400).json({ error: "invalid_grant" });
  }
  authCodes.delete(code);
  const accessToken = crypto.randomUUID();
  tokens.set(accessToken, { userId: authCode.userId, expiresAt: Date.now() + 3600_000 });
  res.json({ access_token: accessToken, token_type: "Bearer", expires_in: 3600 });
});

// Auth middleware
function requireAuth(req: express.Request, res: express.Response, next: express.NextFunction) {
  const bearer = req.headers.authorization?.slice(7);
  const t = bearer ? tokens.get(bearer) : undefined;
  if (!t || t.expiresAt < Date.now()) return res.status(401).end();
  (req as any).userId = t.userId;
  next();
}

app.post("/mcp", requireAuth, (req, res) => {
  // Wire up MCP server to handle JSON-RPC over HTTP
  console.error(`Authenticated request from user: ${(req as any).userId}`);
});

app.listen(3000, () => console.error("Auth MCP server on port 3000"));
```

## Common mistakes

- **Adding authentication to a local stdio server** — undefined Fix: Stdio servers run as a child process of the host application. The host controls access. Auth is only needed for HTTP-exposed servers.
- **Not requiring PKCE** — undefined Fix: OAuth 2.1 mandates PKCE for public clients. MCP clients are public clients. Always require S256 code_challenge_method.
- **Storing tokens in memory only** — undefined Fix: In-memory token stores are lost on restart. Use a database or Redis for production deployments.
- **Not setting token expiration** — undefined Fix: Always set expires_in on tokens and reject expired tokens. Implement refresh tokens for long-lived sessions.
- **Running OAuth over HTTP instead of HTTPS** — undefined Fix: 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

## 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.

---

Source: https://www.rapidevelopers.com/mcp-tutorial/how-to-add-authentication-to-mcp-server
© RapidDev — https://www.rapidevelopers.com/mcp-tutorial/how-to-add-authentication-to-mcp-server
