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.
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.
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-mcpPython 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+ ascurl.exe) or PowerShell'sInvoke-RestMethod- The MCP Inspector — no install needed, run via
npx
Windows / PowerShell users: copy-pasted
curlcommands 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 theaudiencevalue 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-depsflag is needed here: theagentspackage pins@cloudflare/workers-types@^4.xwhile the latestwranglerwants^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(notSSE, not the defaultSTDIO) - 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
Secureand the__Host-name prefix, which some browsers (Brave with Shields on, in particular) refuse to store onhttp://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, andaudiencelocally — no round-trip to Auth0 per request. - Scopes enforce least privilege. A valid, correctly-signed token with no
scopeclaim can still authenticate (/api/meworks) but is rejected by scope-gated routes (403 Forbidden) — authentication and authorization are checked separately. .dev.varsis local-only. Cloudflare Workers have no filesystem in production;wrangler devreads.dev.vars, but a deployed Worker only sees values pushed viawrangler 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_KEYso 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
- Demo source: cloudflare/ai —
demos/remote-mcp-auth0 - Walkthrough reference: Secure and Deploy Remote MCP Servers with Auth0 and Cloudflare (Auth0 blog)
- Model Context Protocol specification and Inspector
License
<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
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
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.
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.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
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.
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.
E2B
Using MCP to run code via e2b.
Neon Database
MCP server for interacting with Neon Management API and databases
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.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.