← Back to DevBytes

How to Secure MCP Servers with OAuth 2.0

How to Secure MCP Servers with OAuth 2.0

The Model Context Protocol (MCP) has rapidly become the standard way to connect AI assistants to external tools, data sources, and services. However, with great connectivity comes great responsibility: an unsecured MCP server can expose sensitive APIs, internal databases, and proprietary logic to anyone who can reach it. OAuth 2.0 is the industry-standard authorization framework that lets you lock down an MCP server while still allowing legitimate clients to access the resources they need.

In this tutorial, you'll learn what OAuth 2.0 looks like in the context of MCP, why it matters, how to implement it end-to-end, and which best practices you should follow in production.

What Is OAuth 2.0 in the Context of MCP?

OAuth 2.0 is an authorization framework that lets a resource owner grant a third-party client limited access to their resources on a server, without sharing credentials. In the MCP world, the "resource server" is your MCP server, the "client" is an MCP-compatible AI host (such as Claude Desktop, an IDE extension, or a custom agent), and the "resource owner" is the end user whose data the agent wants to touch.

When OAuth 2.0 is enabled, the MCP client must obtain an access token from an authorization server before it can call any tool or read any resource exposed by your MCP server. The token encodes who the user is, what scopes they granted, and when the token expires. Your MCP server validates the token on every request and only executes the requested operation if the token is valid and has the right scopes.

MCP itself is transport-agnostic, but the most common production deployment uses HTTP with Server-Sent Events (SSE) or the newer Streamable HTTP transport. OAuth 2.0 fits naturally on top of HTTP, which is why it is the recommended mechanism for any MCP server exposed beyond a single trusted machine.

Why Securing MCP Servers Matters

Many early MCP tutorials show servers running on localhost with no authentication at all. That is fine for experimentation, but it breaks down the moment you deploy a server that:

Without authentication, any process that can reach the server's endpoint can invoke any tool. With OAuth 2.0, you get three concrete benefits: identity (you know who is calling), authorization (you can restrict what each caller may do via scopes), and auditability (you can log the subject and scopes of every request). You also get a clean revocation story: when a user leaves an organization or revokes an app, their tokens stop working.

How OAuth 2.0 Flows Work with MCP

MCP clients that support OAuth follow a discovery-and-handshake sequence before sending any tool calls. The high-level flow is:

For machine-to-machine scenarios where no user is present, the Client Credentials grant is used instead: the client authenticates directly with the authorization server using its own credentials and receives a token scoped to its own identity.

Implementing an OAuth-Protected MCP Server

Let's build a small MCP server in TypeScript that exposes one tool and protects it with OAuth 2.0. We'll use @modelcontextprotocol/sdk for the MCP layer and a lightweight token-validation middleware built on the jose library for JWT verification. The same pattern works in Python with fastmcp and authlib.

First, install the dependencies:

npm install @modelcontextprotocol/sdk express jose
npm install -D typescript @types/express @types/node

Next, create the token validation middleware. This middleware extracts the bearer token from the Authorization header, verifies its signature against the authorization server's JSON Web Key Set (JWKS), checks the issuer and audience, and attaches the decoded claims to the request for downstream use.

// auth.ts
import { createRemoteJWKSet, jwtVerify } from "jose";

const JWKS = createRemoteJWKSet(
  new URL("https://auth.example.com/.well-known/jwks.json")
);

export interface AuthenticatedRequest {
  token: {
    sub: string;
    scope: string;
    exp: number;
    [k: string]: unknown;
  };
}

export async function validateToken(
  authorizationHeader: string | undefined
): Promise<AuthenticatedRequest["token"]> {
  if (!authorizationHeader || !authorizationHeader.startsWith("Bearer ")) {
    throw new Error("missing_bearer_token");
  }

  const token = authorizationHeader.slice("Bearer ".length);

  const { payload } = await jwtVerify(token, JWKS, {
    issuer: "https://auth.example.com",
    audience: "mcp-server-example",
    algorithms: ["RS256"],
  });

  return payload as AuthenticatedRequest["token"];
}

export function hasScope(token: AuthenticatedRequest["token"], scope: string) {
  const scopes = (token.scope ?? "").split(" ");
  return scopes.includes(scope);
}

Now create the MCP server itself. We'll expose a single tool called get_account_balance that requires the accounts:read scope. The server uses the Streamable HTTP transport, and we wrap it in an Express app so we can attach the OAuth middleware.

// server.ts
import express from "express";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { validateToken, hasScope } from "./auth";

const app = express();
app.use(express.json());

// MCP endpoint protected by OAuth
app.post("/mcp", async (req, res) => {
  let token;
  try {
    token = await validateToken(req.headers.authorization);
  } catch (err) {
    res.setHeader(
      "WWW-Authenticate",
      'Bearer realm="mcp", error="invalid_token"'
    );
    return res.status(401).json({ error: "unauthorized" });
  }

  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });

  const server = new Server(
    { name: "secure-banking-mcp", version: "1.0.0" },
    { capabilities: { tools: {} } }
  );

  server.setRequestHandler(ListToolsRequestSchema, async () => ({
    tools: [
      {
        name: "get_account_balance",
        description: "Returns the balance for the authenticated user's primary account.",
        inputSchema: {
          type: "object",
          properties: {
            accountId: { type: "string" },
          },
          required: ["accountId"],
        },
      },
    ],
  }));

  server.setRequestHandler(CallToolRequestSchema, async (request) => {
    if (request.params.name !== "get_account_balance") {
      throw new Error(`Unknown tool: ${request.params.name}`);
    }

    if (!hasScope(token, "accounts:read")) {
      return {
        isError: true,
        content: [{ type: "text", text: "Insufficient scope: accounts:read required" }],
      };
    }

    const { accountId } = request.params.arguments as { accountId: string };
    // In real code, look up the balance for token.sub and accountId.
    const balance = await lookupBalance(token.sub, accountId);

    return {
      content: [{ type: "text", text: `Balance for ${accountId}: $${balance}` }],
    };
  });

  await server.connect(transport);
  await transport.handleRequest(req, res);
});

async function lookupBalance(userId: string, accountId: string): Promise<number> {
  // Replace with a real database lookup.
  return 1234.56;
}

app.listen(3000, () => {
  console.log("Secure MCP server listening on http://localhost:3000/mcp");
});

Notice three important details in this implementation. First, the WWW-Authenticate response header on 401 responses is what tells MCP clients that OAuth is required and where to start the flow. Second, scope checks happen inside the tool handler, not just at the transport boundary — this lets different tools require different scopes. Third, the user identity (token.sub) is used when looking up data, so one user can never read another user's account balance even if they share the same server.

Configuring the Authorization Server

Your MCP server is only as secure as the authorization server that issues its tokens. You can run your own (Keycloak, Authentik, Ory Hydra, or a hosted service like Auth0, Okta, or AWS Cognito) — the requirements are the same:

Here is a minimal example of an Auth0-style scope definition for an MCP-protected API:

{
  "identifier": "mcp-server-example",
  "scopes": [
    { "name": "accounts:read",  "description": "Read account balances and history" },
    { "name": "accounts:write", "description": "Initiate transfers and update accounts" },
    { "name": "admin:read",     "description": "Read administrative reports" }
  ]
}

Connecting an MCP Client

On the client side, MCP hosts that support OAuth will perform the flow automatically once they detect the 401 response. If you are writing your own client with the SDK, you provide an OAuth provider implementation that handles the redirect and token exchange:

// client.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const transport = new StreamableHTTPClientTransport(
  new URL("https://mcp.example.com/mcp"),
  {
    requestInit: {
      headers: {
        Authorization: `Bearer ${process.env.MCP_ACCESS_TOKEN}`,
      },
    },
  }
);

const client = new Client({ name: "demo-client", version: "1.0.0" });
await client.connect(transport);

const tools = await client.listTools();
console.log("Available tools:", tools.tools.map((t) => t.name));

In a fully interactive host, you would replace the static MCP_ACCESS_TOKEN with an OAuth flow that opens a browser, captures the redirect, and refreshes tokens as needed. The SDK's auth helpers can manage this for you.

Best Practices

Conclusion

Securing an MCP server with OAuth 2.0 turns an open tool endpoint into a properly governed API: every call is tied to an authenticated identity, every action is bounded by explicit scopes, and every request is auditable. The implementation is straightforward — a bearer-token middleware in front of your MCP transport, a JWT-verification step against your authorization server's JWKS, and per-tool scope checks that tie data access to the token subject. Pair that with short-lived tokens, TLS everywhere, and disciplined logging, and you have an MCP deployment that is safe to expose to real users and real agents in production.

— Ad —

Google AdSense will appear here after approval

← Back to all articles