Back to Blog
DevelopmentMar 9, 202617 min read

How to Add Authentication to Your MCP Server — OAuth 2.1, Bearer Tokens, and What the Spec Actually Requires (2026)

NT

Nikhil Tiwari

MCP Playground

📖 TL;DR — Key Takeaways

  • Remote MCP servers are unauthenticated by default — anyone with your URL can call your tools
  • Three practical approaches: Bearer token (simplest, internal tools), Cloudflare Access (zero-code proxy auth), OAuth 2.1 with PKCE (spec-compliant, for public servers)
  • The MCP spec (November 2025) mandates OAuth 2.1 + PKCE for public remote servers — PKCE is non-negotiable, all endpoints must be HTTPS
  • The five most common auth mistakes: token passthrough, no audience validation, redirect URI mismatch, missing CSRF state, storing tokens in plaintext
  • For most team tools and internal servers, a Bearer token header is sufficient and much simpler than full OAuth

You built a remote MCP server. It works. You deployed it to Cloudflare Workers or a VPS and pasted the URL into Claude Desktop. Everything runs perfectly — and then it occurs to you: that URL is public. Anyone who discovers it can call your tools, read your data, and exhaust your API quotas.

Authentication for remote MCP servers is one of the most consistently under-documented parts of the MCP ecosystem. The official specification covers what must be implemented for a spec-compliant public server, but it doesn't tell you what to do when you just want to protect a team tool from unauthorized access without implementing a full OAuth authorization server.

This guide covers the full picture — from the simplest approach (a static Bearer token that takes five minutes) to full OAuth 2.1 with PKCE (which is what public-facing MCP servers genuinely need). It also covers the five security mistakes that real implementations have made — some of which led to one-click account takeovers when discovered.


Why Authentication Matters for Remote MCP

A local stdio MCP server — one that runs as a subprocess on your machine — doesn't need auth. It's only accessible from your machine, launched by Claude Desktop under your user account. The operating system is your access control.

A remote MCP server is fundamentally different. It's an HTTP endpoint, publicly routable, serving requests from any source that knows the URL. Without authentication:

  • Anyone who discovers your URL can call your tools — including write operations that modify data
  • Your API quotas (OpenAI, database calls, external services) can be exhausted by unauthorized requests
  • If your tools access private data, that data is exposed to anyone who probes your endpoint
  • Your Cloudflare Workers or server compute costs can be driven up by abuse

The MCP November 2025 specification addresses this directly: remote MCP servers intended for public use must implement OAuth 2.1 with PKCE. For internal or team tools, simpler approaches are acceptable and often preferable.


Three Approaches — Which One Do You Need?

Approach Best For Complexity Spec Compliant
Bearer Token Personal tools, team servers, internal use 5 minutes No (but not required for private servers)
Cloudflare Access Team access control with SSO, zero code changes 15 minutes No (proxy-level, not MCP-native)
OAuth 2.1 + PKCE Public MCP servers, user-facing integrations, shared tools Several hours Yes

Approach 1 — Bearer Token (Simplest)

For most team tools and personal servers, a static Bearer token is entirely sufficient. The client sends a secret token in the Authorization header with every request. Your server validates it. If it doesn't match, you return 401.

Server-Side: Validate the Token in Your Worker

// In your Cloudflare Worker or MCP server fetch handler:
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // Validate Bearer token before routing to MCP
    const authHeader = request.headers.get("Authorization");
    const expectedToken = env.MCP_SECRET_TOKEN; // stored as a Wrangler secret

    if (!authHeader || authHeader !== `Bearer ${expectedToken}`) {
      return new Response("Unauthorized", {
        status: 401,
        headers: { "WWW-Authenticate": "Bearer" },
      });
    }

    // Auth passed — route to your MCP handler
    return McpAgent.serve("/mcp", MyMcpServer)(request, env);
  },
};

Store the secret with Wrangler — never hardcode it:

npx wrangler secret put MCP_SECRET_TOKEN
# Paste your secret when prompted — it's stored encrypted

Client-Side: Configure Claude Desktop

Claude Desktop (and most MCP clients) support static headers for remote server connections. Add your token to ~/.config/claude/claude_desktop_config.json:

{
  "mcpServers": {
    "my-server": {
      "url": "https://my-server.your-account.workers.dev/mcp",
      "headers": {
        "Authorization": "Bearer your-secret-token-here"
      }
    }
  }
}

Security warning: The Bearer token is stored in plaintext in your Claude Desktop config file. Do not commit this file to version control. Use a strong, randomly generated token (32+ characters). Rotate it if you suspect it has been exposed.

Generate a Strong Token

# macOS / Linux
openssl rand -base64 32

# Node.js
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"

When Bearer token is the right choice: you control all the clients, the server is for your team only, and you don't need per-user identity (just "is this caller authorized").


Approach 2 — Cloudflare Access (Zero Code)

If your MCP server runs on Cloudflare Workers, Cloudflare Access can protect it without any changes to your Worker code. Access acts as an identity-aware proxy: it intercepts requests to your Workers URL, challenges unauthenticated users to log in via your configured identity provider (Google, GitHub, Okta, Azure AD, or any SAML/OIDC provider), and only forwards authenticated requests to your Worker.

Setup in the Cloudflare Dashboard

  1. Go to Cloudflare Zero Trust → Access → Applications
  2. Click Add an Application → Self-hosted
  3. Set the Application Domain to your Worker's URL (e.g. my-server.your-account.workers.dev)
  4. Configure your identity provider (Google OAuth is the quickest)
  5. Create a policy: allow users matching your email domain or specific email addresses
  6. Save — Access is now protecting your Worker

Authenticated requests from allowed users pass through. Unauthenticated requests get a Cloudflare Access login page before they reach your Worker at all.

Note: Cloudflare Access works well for human-initiated sessions via a browser. For MCP clients like Claude Desktop connecting programmatically, you'll need to use a Cloudflare Access service token (a client ID + secret pair) and pass it as headers in your Claude Desktop config. This is documented in Cloudflare's MCP Access docs.

When Cloudflare Access is the right choice: you want SSO via Google/GitHub/Okta for your whole team, you don't want to write auth code, and your server is already on Cloudflare Workers.


Approach 3 — OAuth 2.1 with PKCE (Full Spec)

Full OAuth 2.1 is what the MCP specification requires for public remote servers — servers where users you don't personally know will connect their MCP clients. The flow involves an authorization server, a consent screen, and short-lived access tokens rather than static secrets.

How the OAuth 2.1 + PKCE Flow Works in MCP

  1. Client connects to your MCP server without a token → server returns 401 Unauthorized with a WWW-Authenticate header pointing to your Protected Resource Metadata endpoint
  2. Client fetches your metadata at /.well-known/oauth-protected-resource → discovers your authorization server URL and supported scopes
  3. Client generates PKCE pair: a random code_verifier string, and a SHA-256 hash of it called code_challenge
  4. Client redirects user to your auth server with response_type=code, code_challenge, code_challenge_method=S256, and redirect_uri
  5. User authenticates and consents → auth server redirects back to client with an authorization code
  6. Client exchanges the code at your token endpoint, sending the original code_verifier (not the hash) → auth server verifies the hash matches, issues an access token
  7. Client uses the access token as Authorization: Bearer <token> on all subsequent MCP requests
  8. Your MCP server validates the token — checks signature, expiry, and crucially, the aud (audience) claim

PKCE (Proof Key for Code Exchange) is the critical security piece. It prevents an attacker who intercepts the authorization code in step 5 from using it — because to exchange the code for a token, you must provide the original code_verifier that only the legitimate client knows.

The Two Metadata Endpoints Your Server Must Expose

Per the MCP November 2025 spec, your server must implement two well-known endpoints:

// 1. Protected Resource Metadata — RFC 9728
// GET /.well-known/oauth-protected-resource
{
  "resource": "https://your-mcp-server.com",
  "authorization_servers": ["https://your-auth-server.com"],
  "scopes_supported": ["mcp:read", "mcp:write"],
  "bearer_methods_supported": ["header"]
}

// 2. Authorization Server Metadata — RFC 8414
// GET /.well-known/oauth-authorization-server
{
  "issuer": "https://your-auth-server.com",
  "authorization_endpoint": "https://your-auth-server.com/authorize",
  "token_endpoint": "https://your-auth-server.com/token",
  "response_types_supported": ["code"],
  "code_challenge_methods_supported": ["S256"],
  "grant_types_supported": ["authorization_code", "refresh_token"]
}

The MCP client fetches these on first connection to discover where to send users for authorization. If these endpoints don't exist, spec-compliant clients cannot complete the OAuth flow.


What the MCP Spec Actually Requires

The November 2025 MCP specification made several requirements non-negotiable for public remote servers. Here's the plain-English version:

Requirement What It Means in Practice
OAuth 2.1 + PKCE mandatoryEvery public remote MCP server must support the OAuth 2.1 authorization code flow with PKCE (SHA-256). No exceptions.
HTTPS everywhereAll authorization endpoints, token endpoints, and the MCP server URL must be served over HTTPS. HTTP is not allowed for any auth-related endpoint.
Protected Resource MetadataYour server must expose /.well-known/oauth-protected-resource per RFC 9728 so clients can discover your auth server.
PKCE S256 onlyOnly SHA-256 PKCE is acceptable. The older plain method is insecure and must not be accepted.
Audience validationYour server must validate the aud claim in access tokens to confirm the token was issued specifically for your server — not just for any resource.
No token passthroughIf your MCP server calls downstream APIs, it must use its own credentials — not forward the client's access token to third-party services.

Important context: These requirements apply to public remote MCP servers intended for use by third parties. If you're building an internal team tool behind a firewall or VPN, a Bearer token or Cloudflare Access is acceptable and pragmatic. The spec's intent is to protect end users connecting to servers they don't control.


OAuth on Cloudflare Workers: workers-oauth-provider

Cloudflare provides a first-party library — workers-oauth-provider — that implements the OAuth 2.1 provider side for Workers. It handles the authorization endpoint, token endpoint, token storage (via KV), and PKCE validation. You configure it with your identity provider of choice.

Setup

npm install workers-oauth-provider

# Create the KV namespace for token storage
npx wrangler kv namespace create "OAUTH_KV"
# Copy the ID it returns into wrangler.jsonc
// wrangler.jsonc additions:
{
  "kv_namespaces": [
    {
      "binding": "OAUTH_KV",
      "id": "YOUR_KV_NAMESPACE_ID"
    }
  ]
}

Implement the OAuth Provider in Your Worker

import OAuthProvider from "workers-oauth-provider";
import { McpAgent } from "cloudflare/agents/mcp";
import { MyMcpServer } from "./mcp-server";

export default new OAuthProvider({
  // Your MCP server URL — used in the Protected Resource Metadata
  apiHandlers: {
    "/mcp": async (request, env, ctx, props) => {
      // props.claims contains the validated token claims
      // props.accessToken is the validated access token
      return McpAgent.serve("/mcp", MyMcpServer)(request, env, ctx);
    },
  },

  // GitHub OAuth as the identity provider
  defaultHandler: async (request, env) => {
    const url = new URL(request.url);

    // Authorization endpoint — redirect to GitHub
    if (url.pathname === "/authorize") {
      const githubAuthUrl = new URL("https://github.com/login/oauth/authorize");
      githubAuthUrl.searchParams.set("client_id", env.GITHUB_CLIENT_ID);
      githubAuthUrl.searchParams.set("scope", "read:user user:email");
      githubAuthUrl.searchParams.set(
        "redirect_uri",
        `${url.origin}/callback`
      );
      return Response.redirect(githubAuthUrl.toString());
    }

    // Callback — exchange GitHub code for access token
    if (url.pathname === "/callback") {
      const code = url.searchParams.get("code");
      const tokenRes = await fetch(
        "https://github.com/login/oauth/access_token",
        {
          method: "POST",
          headers: { Accept: "application/json" },
          body: JSON.stringify({
            client_id: env.GITHUB_CLIENT_ID,
            client_secret: env.GITHUB_CLIENT_SECRET,
            code,
          }),
        }
      );
      const { access_token } = await tokenRes.json();
      // Return the token to the OAuth provider library
      return new Response(JSON.stringify({ access_token }), {
        headers: { "Content-Type": "application/json" },
      });
    }

    return new Response("Not found", { status: 404 });
  },
});

Set your GitHub OAuth app credentials as Wrangler secrets:

npx wrangler secret put GITHUB_CLIENT_ID
npx wrangler secret put GITHUB_CLIENT_SECRET

Security note: The workers-oauth-provider library stores tokens by hash only — the plaintext token is never written to KV storage. Even if your KV namespace were exposed, tokens could not be derived from it. This is the correct implementation pattern per the MCP spec.


5 Security Mistakes That Lead to Real Vulnerabilities

These are not theoretical. Several of these mistakes were discovered in production MCP implementations in 2025 and reported as vulnerabilities.

1. Token Passthrough (Most Common)

What it is:

Your MCP server receives an access token from the MCP client, then passes that same token directly to a downstream API (e.g., your database, a third-party service).

Why it's dangerous:

The token was issued for your MCP server, not for the downstream API. Forwarding it means the downstream service receives a token it has no way to properly validate. It also means if the downstream service is compromised, it holds tokens that can be replayed against your MCP server.

The fix:

Your MCP server must use its own credentials to call downstream APIs — separate service account keys, stored as Wrangler secrets, never derived from the client's token.

2. Missing Audience Validation

A JWT access token contains an aud (audience) claim that identifies the intended recipient. If your server accepts any valid token from your authorization server — without checking that aud matches your server's resource identifier — an attacker with a token issued for a different service at the same auth server can call your MCP tools.

// Always validate the audience claim:
const payload = jwt.verify(token, publicKey);
if (payload.aud !== "https://your-mcp-server.com") {
  return new Response("Invalid audience", { status: 401 });
}

3. Redirect URI Mismatch (Allows Authorization Code Theft)

During Dynamic Client Registration, your server must strictly validate redirect URIs. If you accept wildcard patterns or overly broad URI matching, an attacker can register a redirect URI pointing to a domain they control, then trick a user into initiating an OAuth flow where the authorization code is sent to the attacker.

The fix: only allow localhost for development, custom app schemes (e.g. cursor://), or explicitly pre-approved HTTPS domains. Note that localhost and 127.0.0.1 are treated as different strings by strict URI comparisons — handle both explicitly.

4. Missing CSRF State Parameter

The OAuth state parameter is not optional. It must be a cryptographically random value, stored in the client session before the authorization redirect, and verified when the authorization code callback arrives. Without it, an attacker can craft a link that, when clicked by a logged-in user, delivers an authorization code from the attacker's account to the victim — resulting in session fixation attacks. This exact vulnerability was discovered in MCP implementations in July–August 2025.

5. Storing Tokens in Plaintext

If your server stores access tokens, refresh tokens, or authorization codes in a database or KV store, store them as hashed values only (SHA-256 minimum). The Cloudflare workers-oauth-provider library does this correctly — tokens are hashed before storage and the plaintext is never persisted. If your KV store is ever exposed, hashed tokens cannot be used directly by an attacker.


Decision Guide: Which Approach to Use

Use Bearer Token if:

  • The server is for your own use or your immediate team
  • You control every client that will connect
  • You don't need per-user identity — just access control
  • You want to be running in 5 minutes

Use Cloudflare Access if:

  • Your server runs on Cloudflare Workers
  • You want SSO via Google, GitHub, or Okta for your team
  • You don't want to write any auth code
  • You need audit logs of who connected when

Use OAuth 2.1 + PKCE if:

  • You're building a public MCP server for third-party users
  • You need per-user identity and authorization scopes
  • Your server will be listed in a public MCP directory
  • You need to be compliant with the MCP specification
  • Your server accesses user-owned data on their behalf

Frequently Asked Questions

Does Claude Desktop support OAuth 2.1 flows out of the box?+
Claude Desktop supports remote MCP servers via URL with optional static headers. For full OAuth 2.1 with browser-based consent flows, support varies by client version — check the current Claude Desktop release notes. Most MCP clients that support remote servers implement at least Bearer token headers. When in doubt, test your server with MCP Playground to see how it responds to unauthenticated requests before deploying.
Can I use Auth0, Okta, or another existing identity provider?+
Yes. Auth0 has published a dedicated guide for securing remote MCP servers (auth0.com). For any OIDC-compatible provider (Okta, Azure AD, Google Identity), you configure your MCP server to validate JWTs issued by that provider. The workers-oauth-provider library supports custom handlers for any OAuth provider.
What is PKCE and why can't I skip it?+
PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks. Without it, if an attacker intercepts the authorization code (possible in some redirect flows), they can exchange it for an access token. PKCE requires the original code requestor to provide a secret verifier when exchanging the code — an intercepted code alone is useless without it. The MCP spec mandates SHA-256 PKCE for all clients. The plain PKCE method must not be accepted.
What is the difference between authentication and authorization in MCP?+
Authentication answers "who is this?" — verifying the identity of the connecting client or user. Authorization answers "what are they allowed to do?" — determining which tools they can call and which data they can access. OAuth handles both: the access token proves identity (authentication) and carries scopes that define permissions (authorization). For internal tools with a single Bearer token, you typically only have authentication — anyone with the token has full access.
Do I need auth for a local stdio MCP server?+
No. A local stdio server runs as a subprocess on your machine, launched by your MCP client. The operating system enforces access — only your user account can spawn the process. There is no network exposure and no need for authentication. Authentication only becomes relevant when your MCP server is reachable over HTTP from external clients.

Related Guides


Resources:

NT

Written by Nikhil Tiwari

15+ years in product development. AI enthusiast building developer tools that make complex technologies accessible to everyone.