mcp-auth0-oidc

mcp-auth0-oidc

A remote MCP server secured with Auth0 OAuth 2.1, deployed on Cloudflare Workers, enabling AI clients to call tools on a JWT-protected todo API.

Category
Visit Server

README

Secure MCP: Deploying Protected Remote MCP Servers on Cloudflare Workers with Auth0

A hands-on tutorial for securing and deploying remote MCP (Model Context Protocol) servers using Cloudflare Workers and Auth0. It walks through two connected pieces — a JWT-protected REST API and an OAuth-protected remote MCP server — from local development to production deployment, and documents every real error you're likely to hit along the way.

Follow on X WhatsApp Channel Reddit License

Python TypeScript Cloudflare Workers Auth0 Node.js MCP


What this is

The Model Context Protocol (MCP) lets AI clients (Claude, IDEs, custom agents) call tools exposed by a server. When that server is remote — reachable over HTTP instead of running as a local subprocess — it needs the same access controls as any other API: you don't want an anonymous caller invoking tools that touch real user data.

This repo demonstrates the standard pattern for that: a resource server (a REST API) that validates JWTs on every request, and an MCP server that runs a full OAuth 2.1 authorization flow — login, consent, token exchange — before it lets an MCP client call its tools. Both are deployed as Cloudflare Workers; both are secured by Auth0.

<p align="center"> <img src="assets/architecture-diagram.png" alt="Architecture: User → Claude Desktop → remote-mcp proxy → Remote MCP Server (Cloudflare) → Todos API (Cloudflare), authenticated via Auth0" width="700"> </p>

Repository layout

secure-mcp/
├── main.py                              # placeholder Python entry point (this repo's own package)
├── pyproject.toml                       # Python project metadata (FastMCP dependency)
├── cloudflare-todoapi-github/
│   └── ai/
│       └── demos/
│           └── remote-mcp-auth0/
│               ├── todos-api/           # Part 1 — Auth0-protected REST API (Hono + Cloudflare Workers)
│               └── mcp-auth0-oidc/      # Part 2 — Remote MCP server with Auth0 OAuth login
└── README.md

cloudflare-todoapi-github/ai is a vendored copy of cloudflare/ai, which ships a large collection of Workers AI / MCP demos. This tutorial focuses on the two folders under demos/remote-mcp-auth0/.

Prerequisites

  • Node.js 18+ and npm
  • Python 3.12+ (only needed if you extend the secure-mcp Python package itself; the two demo projects are pure TypeScript)
  • A free Cloudflare account — Workers' free tier (100k requests/day) is enough for this whole tutorial
  • A free Auth0 account
  • curl (ships with Windows 10+ as curl.exe) or PowerShell's Invoke-RestMethod
  • The MCP Inspector — no install needed, run via npx

Windows / PowerShell users: copy-pasted curl commands from documentation use bash syntax (\ line continuation, single quotes) that PowerShell doesn't understand. See the PowerShell cheat sheet below before you start pasting commands.


Part 1 — Secure REST API (todos-api)

This is a small Hono app that exposes a few endpoints and validates every request's Authorization: Bearer <token> header against Auth0's public keys (JWKS) before responding.

Route Auth required Scope required
GET /api/health No
GET /api/me Yes (valid JWT)
GET /api/todos Yes read:todos
GET /api/billing Yes read:billing

1. Create the Auth0 API

In the Auth0 Dashboard, go to Applications → APIs → Create API:

  • Name: todos-api (or anything)
  • Identifier: urn:todos-api — this becomes the audience value everywhere below

Under the API's Permissions tab, add read:todos and read:billing.

2. Configure local secrets

cd cloudflare-todoapi-github/ai/demos/remote-mcp-auth0/todos-api
npm install

Create a .dev.vars file in this folder (never committed — it's in .gitignore):

AUTH0_DOMAIN=your-tenant.us.auth0.com
AUTH0_AUDIENCE=urn:todos-api

3. Run it locally

npm run dev

Wrangler starts the Worker at http://127.0.0.1:8789.

4. Get an access token

Create a Machine to Machine application in Auth0 (Applications → Create Application → Machine to Machine), authorize it for the todos-api API, and grant it the scopes you want to test.

$body = @{
    client_id     = "YOUR_M2M_CLIENT_ID"
    client_secret = "YOUR_M2M_CLIENT_SECRET"
    audience      = "urn:todos-api"
    grant_type    = "client_credentials"
} | ConvertTo-Json

$response = Invoke-RestMethod -Method Post -Uri "https://your-tenant.us.auth0.com/oauth/token" -ContentType "application/json" -Body $body
$token = $response.access_token

5. Call the protected API

Invoke-RestMethod -Method Get -Uri "http://127.0.0.1:8789/api/me" -Headers @{ authorization = "Bearer $token" }

You should see the token's claims echoed back (iss, sub, aud, scope, …). A 401 Unauthorized at this point almost always means a typo in AUTH0_DOMAIN/AUTH0_AUDIENCE, or the token's aud claim doesn't match urn:todos-api.

6. Deploy

npx wrangler deploy

Then set the same secrets on the deployed Worker. Interactive wrangler secret put on Windows is prone to mangling pasted values (stray quotes / trailing newlines end up baked into the secret) — the reliable way is a bulk file:

Set-Content -Path secrets.json -Encoding ascii -Value '{"AUTH0_DOMAIN":"your-tenant.us.auth0.com","AUTH0_AUDIENCE":"urn:todos-api"}'
npx wrangler secret bulk secrets.json
Remove-Item secrets.json

Secret names are case- and character-exact — AUTH0_DOMAIN and AUTHO_DOMAIN (letter O instead of zero) look identical at a glance in the dashboard and will silently break auth with a confusing 500 Internal Server Error instead of a clean 401:

<p align="center"> <img src="assets/cloudflare-secrets-panel.png" alt="Cloudflare Workers Variables and secrets panel — values are shown as 'Value encrypted', so a typo in the name is easy to miss" width="650"> </p>

7. Test the deployment

curl.exe --request GET --url "https://your-worker-name.workers.dev/api/me" --header "authorization: Bearer $token"

Part 2 — Secure Remote MCP Server (mcp-auth0-oidc)

This Worker exposes an MCP endpoint at /mcp and wraps it in a full OAuth 2.1 Authorization Code flow: an MCP client that hasn't authenticated gets redirected through Auth0 login and a consent screen before it can call any tool. It uses the todos-api from Part 1 as the downstream resource it acts on behalf of the user.

<p align="center"> <img src="assets/auth0-cloudflare-flow.png" alt="Sequence diagram: MCP client authenticates via the Remote MCP Server, which redirects to Auth0, exchanges the code for tokens, then calls the Todos API on the user's behalf" width="750"> </p>

1. Create an Auth0 Regular Web Application

Applications → Create Application → Regular Web Application.

Under Settings → Allowed Callback URLs, add (comma-separated as you add more later):

http://localhost:8788/callback

Note the Client ID and Client Secret — you'll need both.

2. Authorize this application for the API

This step is easy to miss and produces an opaque error if skipped. Go to Applications → APIs → todos-api → Machine To Machine Applications (or the app's own APIs tab) and explicitly authorize the MCP server's application, ticking the scopes it should be able to request (read:todos, etc.). Without this, Auth0 rejects the login redirect with:

error_description: Client "..." is not authorized to access resource server "urn:todos-api".

even though the application type and callback URL are both correct.

3. Create a KV namespace

The OAuth provider uses Workers KV to store authorization state:

cd ../mcp-auth0-oidc
npx wrangler kv namespace create OAUTH_KV

Copy the returned id into wrangler.jsonc under the matching kv_namespaces binding.

4. Configure local secrets

npm install --legacy-peer-deps

The --legacy-peer-deps flag is needed here: the agents package pins @cloudflare/workers-types@^4.x while the latest wrangler wants ^5.x. It's a soft (peerOptional) conflict — safe to bypass, not a real incompatibility.

Create .dev.vars:

AUTH0_DOMAIN=your-tenant.us.auth0.com
AUTH0_CLIENT_ID=your-regular-web-app-client-id
AUTH0_CLIENT_SECRET=your-regular-web-app-client-secret
AUTH0_AUDIENCE=urn:todos-api
AUTH0_SCOPE=openid profile email offline_access read:todos
API_BASE_URL=http://127.0.0.1:8789
NODE_ENV=development

Plus a random signing key for the consent-approval cookie:

node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
COOKIE_ENCRYPTION_KEY=<paste the generated hex string>

Without COOKIE_ENCRYPTION_KEY, approving the consent screen fails server-side with Error: cookieSecret is required for signing cookies.

5. Run both Workers together

The MCP server calls the Todos API, so run both at once, in separate terminals:

# terminal 1
cd todos-api
npm run dev        # http://127.0.0.1:8789

# terminal 2
cd mcp-auth0-oidc
npm run dev         # http://127.0.0.1:8788

6. Test with the MCP Inspector

# terminal 3
npx @modelcontextprotocol/inspector

This opens a browser tab with a session-token URL. In the sidebar:

  • Transport Type: Streamable HTTP (not SSE, not the default STDIO)
  • URL: http://localhost:8788/mcp
  • Click Connect

You'll be redirected through Auth0 login, then a consent screen ("MCP Inspector is requesting access…"). Approve it, and the Inspector connects and lists the server's tools.

If Approve fails with "Missing CSRF token cookie": this is a browser issue, not a config issue. The consent cookie is set with Secure and the __Host- name prefix, which some browsers (Brave with Shields on, in particular) refuse to store on http://localhost. Use Chrome/Edge, or disable Shields for the page, and retry the full connect flow (the CSRF token is one-time-use, so a stale attempt won't work — reconnect from scratch).

7. Deploy

npx wrangler deploy

Set the same variables as secrets on the deployed Worker (via wrangler secret bulk, as in Part 1), then add the deployed callback URL to the Auth0 application's Allowed Callback URLs:

http://localhost:8788/callback, https://your-mcp-worker.workers.dev/callback

Security notes

  • JWT validation is signature-based, not a database lookup. The API fetches Auth0's public keys once (JWKS) and verifies every token's signature, issuer, and audience locally — no round-trip to Auth0 per request.
  • Scopes enforce least privilege. A valid, correctly-signed token with no scope claim can still authenticate (/api/me works) but is rejected by scope-gated routes (403 Forbidden) — authentication and authorization are checked separately.
  • .dev.vars is local-only. Cloudflare Workers have no filesystem in production; wrangler dev reads .dev.vars, but a deployed Worker only sees values pushed via wrangler secret put / wrangler secret bulk. Never commit .dev.vars — it's excluded in this repo's .gitignore.
  • CSRF + signed cookies protect the consent flow. The MCP server's OAuth implementation issues a one-time CSRF token bound to a cookie before rendering the consent form, and signs the "approved clients" cookie with COOKIE_ENCRYPTION_KEY so it can't be forged client-side.
  • Rotate anything that leaked. If a client secret or access token is ever pasted into a chat log, shared terminal, or committed by mistake, rotate it in the Auth0 dashboard (Application → Settings → Rotate Secret) — treat exposure as compromise regardless of how it happened.

PowerShell cheat sheet

Most MCP/Auth0 tutorials show bash-style curl. On Windows PowerShell, translate:

Bash PowerShell
curl ... curl.exe ... (plain curl is aliased to Invoke-WebRequest, which takes different flags)
\ at end of line ` (backtick) at end of line — nothing may follow it, not even a space
'single quotes' "double quotes" (needed for $variable expansion)
multi-line JSON body PowerShell here-string: <br>$json = @'<br>{ ... }<br>'@ — closing '@ must start at column 0

For scripted calls, Invoke-RestMethod is usually more convenient than curl — it parses JSON responses into objects automatically:

$response = Invoke-RestMethod -Method Post -Uri "..." -ContentType "application/json" -Body $json
$response.access_token

Troubleshooting

Symptom Cause Fix
npm error ERESOLVE ... @cloudflare/workers-types A dependency pins workers-types v4 while wrangler wants v5 npm install --legacy-peer-deps
Missing expression after unary operator '--' in PowerShell Pasted a bash curl command with \ line continuations Replace \ with `, use curl.exe
404 Not Found on a valid-looking URL Hit a route the app doesn't define (e.g. / instead of /api/me) Check the server's route table in its source
401 with WWW-Authenticate: ... "Token verification failure" Deployed secret values don't exactly match what the token was issued for (typo, stray whitespace) Re-push secrets with wrangler secret bulk; verify names with wrangler secret list
500 Internal Server Error, no useful browser message Unhandled exception in the Worker (often a missing/misnamed secret) npx wrangler tail while reproducing — prints the real exception
Inspector: 'fastmcp' is not recognized... / spawn fastmcp ENOENT Inspector UI kept a stale STDIO transport config from a previous (unrelated) server In the sidebar, switch Transport Type to Streamable HTTP and set the URL explicitly
Missing CSRF token cookie on consent approval Browser refused a Secure/__Host- cookie on http://localhost Use Chrome/Edge, or disable Brave Shields for the page; reconnect from scratch
Error: cookieSecret is required for signing cookies COOKIE_ENCRYPTION_KEY missing from .dev.vars Generate one, add it, restart wrangler dev
Callback URL mismatch on the Auth0 login page redirect_uri the app sent isn't in Allowed Callback URLs Add the exact URL (scheme + host + port + path) in Auth0 Application settings
Client "..." is not authorized to access resource server "..." The application isn't linked to the API in Auth0 Authorize the app under the API's Machine To Machine Applications tab, with the needed scopes

Credits

License

MIT


<p align="center"> If this helped you, a follow keeps more of this coming:<br> <a href="https://x.com/mcoding_off">X / Twitter</a> · <a href="https://whatsapp.com/channel/0029Vb7WRtT11ulGgJPp4m3y">WhatsApp Channel</a> · <a href="https://www.reddit.com/user/mcoding_off/">Reddit</a> </p>

Recommended Servers

playwright-mcp

playwright-mcp

A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.

Official
Featured
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.

Official
Featured
Local
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.

Official
Featured
Local
TypeScript
VeyraX MCP

VeyraX MCP

Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.

Official
Featured
Local
graphlit-mcp-server

graphlit-mcp-server

The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.

Official
Featured
TypeScript
Kagi MCP Server

Kagi MCP Server

An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.

Official
Featured
Python
E2B

E2B

Using MCP to run code via e2b.

Official
Featured
Neon Database

Neon Database

MCP server for interacting with Neon Management API and databases

Official
Featured
Exa Search

Exa Search

A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.

Official
Featured
Qdrant Server

Qdrant Server

This repository is an example of how to create a MCP server for Qdrant, a vector search engine.

Official
Featured