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:
- Wraps a paid or rate-limited API — such as a CRM, a payments provider, or an LLM gateway — where every call costs money.
- Reads or mutates sensitive user data — emails, documents, calendar entries, or database rows.
- Performs privileged actions — deploying code, sending messages on behalf of a user, or modifying infrastructure.
- Is reachable over the network — for example, when multiple agents or multiple users share a single MCP server.
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:
- The client attempts to connect to the MCP server and receives an HTTP 401 with a
WWW-Authenticateheader pointing to the authorization server. - The client fetches the authorization server's metadata from its well-known endpoint (
/.well-known/oauth-authorization-server). - The client registers itself (dynamically or statically) and redirects the user to the authorization endpoint.
- The user logs in and consents to the requested scopes.
- The authorization server redirects back to the client with an authorization code, which the client exchanges for an access token (and optionally a refresh token).
- The client sends the access token as a
Bearertoken in theAuthorizationheader of every subsequent MCP request.
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:
- Expose
/.well-known/oauth-authorization-serverwith metadata includingauthorization_endpoint,token_endpoint,jwks_uri, andscopes_supported. - Issue access tokens as signed JWTs with RS256 or ES256, never as opaque tokens the MCP server cannot verify locally.
- Define scopes that map cleanly to MCP tool capabilities, such as
accounts:read,accounts:write, andadmin:*. - Support dynamic client registration (RFC 7591) if you want MCP clients to self-register, or pre-register known clients if you operate a closed ecosystem.
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
- Always use TLS. OAuth 2.0 tokens are bearer tokens — anyone who intercepts one can use it until it expires. Serve MCP only over HTTPS, and set the
Secureflag on any cookies you use. - Keep tokens short-lived. Access tokens should expire in 5–60 minutes. Use refresh tokens with rotation for long-lived sessions, and revoke refresh tokens on logout.
- Validate every claim. Check
iss,aud,exp,nbf, and signature on every request. Never trust a token because it "looks like a JWT." - Scope per tool, not per server. Define granular scopes and enforce them inside each tool handler. A single
mcp:usescope gives you authentication without meaningful authorization. - Bind data access to the token subject. Always filter database queries by
token.subor a mapped user ID. Never trust anaccountIdsupplied by the client without verifying ownership. - Log and audit. Record the subject, scopes, tool name, and parameters for every call. This is invaluable for incident response and for understanding agent behavior.
- Rate-limit per token. Even a legitimate client can loop. Apply per-subject rate limits to prevent runaway agents from burning through API quotas.
- Plan for revocation. Either use short token lifetimes or implement a token introspection / revocation endpoint that your MCP server checks on each call.
- Don't expose dynamic client registration publicly without approval. Open dynamic registration lets anyone create a client. Require admin approval or scope it to known redirect URIs.
- Test the failure modes. Verify that expired tokens, wrong-audience tokens, and missing scopes all return the correct 401/403 responses with helpful
WWW-Authenticateheaders.
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.