Most MCP servers start without authentication. You build a tool, wire up stdio, connect it to Claude or Cursor, and it works. But the moment you deploy that server for others to use — or expose it over HTTP — you need auth. OAuth 2.0 is the standard the MCP specification recommends for server-to-client authentication, and it is what most production MCP servers use today.

This guide walks through adding OAuth to an MCP server from scratch. No framework magic. Just the authorization flow, token validation, and the patterns that hold up in production.

Why OAuth for MCP

MCP supports several auth strategies: none, API keys, and OAuth. For single-user local servers, no auth is fine. For shared servers or anything exposed over HTTP/SSE transport, OAuth is the right choice because it separates identity from access. Users authenticate once, get a scoped token, and your server validates that token on every request without ever handling passwords.

The MCP specification defines OAuth as a first-class auth type. Clients like Claude Desktop and VS Code Copilot already handle the OAuth dance on the user’s behalf when they connect to a server that declares "auth": "oauth" in its manifest.

What you need before starting

  • A working MCP server (Node.js or Python) with at least one tool
  • An OAuth provider — GitHub, Google, Auth0, or any provider that supports OAuth 2.0 Authorization Code flow
  • Your server deployed over HTTP or SSE transport (OAuth does not apply to stdio, which runs locally)

For this guide, we will use GitHub as the OAuth provider. The pattern is the same for any provider.

Step 1: Register your OAuth application

Go to your OAuth provider and register a new application. For GitHub:

  1. Navigate to Settings > Developer settings > OAuth Apps > New OAuth App
  2. Set the Authorization callback URL to your server’s callback endpoint: https://your-server.com/oauth/callback
  3. Note your Client ID and Client Secret

Store these as environment variables. Never commit them to your repository.

export OAUTH_CLIENT_ID="your-client-id"
export OAUTH_CLIENT_SECRET="your-client-secret"

Step 2: Declare auth in your server manifest

Your MCP server’s manifest tells clients what authentication is required before they can connect. Add the auth field:

{
  "name": "my-authenticated-server",
  "version": "1.0.0",
  "auth": {
    "type": "oauth",
    "authorization_url": "https://github.com/login/oauth/authorize",
    "token_url": "https://github.com/login/oauth/access_token",
    "scopes": ["read:user", "repo"],
    "client_id": "your-client-id"
  }
}

The scopes array defines what permissions your server needs. Keep these minimal. Request only what your tools actually use.

Step 3: Add the callback endpoint

When a user authorizes your app, the OAuth provider redirects them to your callback URL with an authorization code. Your server exchanges that code for an access token.

In Node.js with Express:

app.get('/oauth/callback', async (req, res) => {
  const { code } = req.query;

  const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Accept': 'application/json'
    },
    body: JSON.stringify({
      client_id: process.env.OAUTH_CLIENT_ID,
      client_secret: process.env.OAUTH_CLIENT_SECRET,
      code
    })
  });

  const { access_token } = await tokenResponse.json();

  // Store the token associated with this session
  // In production, use a session store or database
  req.session.accessToken = access_token;

  res.redirect('/connected');
});

Step 4: Validate tokens on every tool call

Every incoming MCP request should carry an access token. Validate it before executing any tool logic.

function validateToken(req) {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    throw new Error('Missing or invalid authorization header');
  }
  return authHeader.slice(7);
}

// In your MCP tool handler
server.setRequestHandler('tools/call', async (request, context) => {
  const token = validateToken(context.request);

  // Verify the token with the OAuth provider
  const userResponse = await fetch('https://api.github.com/user', {
    headers: { 'Authorization': `Bearer ${token}` }
  });

  if (!userResponse.ok) {
    return { error: { code: -32001, message: 'Invalid or expired token' } };
  }

  const user = await userResponse.json();

  // Now execute the tool with the authenticated user context
  return executeToolWithUser(request.params, user);
});

Step 5: Scope tools by permission

Not every authenticated user should access every tool. Use the OAuth scopes and user identity to control which tools are available.

server.setRequestHandler('tools/list', async (request, context) => {
  const token = validateToken(context.request);
  const scopes = await getTokenScopes(token);

  const allTools = [
    { name: 'read_repos', requiredScope: 'repo' },
    { name: 'read_profile', requiredScope: 'read:user' },
    { name: 'create_issue', requiredScope: 'repo' }
  ];

  // Only return tools the user's token has permission for
  const availableTools = allTools.filter(
    tool => scopes.includes(tool.requiredScope)
  );

  return { tools: availableTools };
});

This means the tool list itself changes based on the user’s permissions. An agent connecting with a read-only token will never see write tools, which prevents confused tool calls and wasted retries.

Step 6: Handle token expiration

OAuth tokens expire. When they do, your server needs to return a clear error so the client can re-authenticate. Do not silently fail or return empty results.

if (userResponse.status === 401) {
  return {
    error: {
      code: -32001,
      message: 'Token expired. Please re-authenticate.',
      data: { action: 'reauthenticate' }
    }
  };
}

If your OAuth provider supports refresh tokens, implement the refresh flow to avoid interrupting the user:

async function refreshAccessToken(refreshToken) {
  const response = await fetch('https://github.com/login/oauth/access_token', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Accept': 'application/json'
    },
    body: JSON.stringify({
      client_id: process.env.OAUTH_CLIENT_ID,
      client_secret: process.env.OAUTH_CLIENT_SECRET,
      grant_type: 'refresh_token',
      refresh_token: refreshToken
    })
  });

  return response.json();
}

Common mistakes

Validating tokens only on connection, not per request. Tokens can be revoked or expire between calls. Check on every tool invocation.

Requesting too many scopes. Users see the permission list before authorizing. Broad scopes reduce trust and conversion. Request only what your tools need.

Storing tokens in memory without cleanup. If your server runs long, tokens accumulate. Use a TTL cache or session store that evicts expired tokens automatically.

Not handling the state parameter. The OAuth spec includes a state parameter to prevent CSRF attacks. Generate a random value before redirecting to the authorization URL and verify it in the callback.

const state = crypto.randomBytes(16).toString('hex');
// Store state in session, verify it matches in callback

Exposing client secrets in client-side code. The client secret must stay on your server. If you are building a public MCP server, use the PKCE extension instead of a client secret.

Testing your OAuth flow

Test the full flow before deploying:

  1. Start your server locally with HTTP transport
  2. Open the authorization URL in a browser with the correct client_id and redirect_uri
  3. Authorize the app and verify the callback receives a code
  4. Check that the token exchange works and your tools respond to authenticated requests
  5. Test with an expired or invalid token to confirm errors are clear

The MCP Inspector tool supports passing custom headers, including Authorization. Use it to test authenticated tool calls without a full client:

echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | \
  curl -X POST https://your-server.com/mcp \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer your-token" \
  -d @-

FAQ

Q: Can I use API keys instead of OAuth? A: Yes, for simple cases. Set "auth": {"type": "api-key"} in your manifest. But API keys do not support scoping, expiration, or user identity out of the box. OAuth is better for multi-user servers or anything with varying permission levels.

Q: Do I need OAuth for stdio servers? A: No. Stdio servers run locally on the user’s machine. The operating system provides the security boundary. OAuth is for servers exposed over HTTP or SSE where requests come from the network.

Q: What if my OAuth provider does not support PKCE? A: Use the standard Authorization Code flow with a client secret on your server. PKCE is preferred for public clients (mobile apps, SPAs) but not required for server-side MCP implementations where the secret stays on the backend.

Q: How do MCP clients handle the OAuth redirect? A: Clients like Claude Desktop open a browser window for the authorization URL, wait for the callback, and store the token for future connections. Your server just needs to implement the standard OAuth endpoints correctly.