mcp-auth0-oidc
README.md
# 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.
[](https://x.com/mcoding_off)
[](https://whatsapp.com/channel/0029Vb7WRtT11ulGgJPp4m3y)
[](https://www.reddit.com/user/mcoding_off/)
[](LICENSE)
[](https://www.python.org/)
[](https://www.typescriptlang.org/)
[](https://workers.cloudflare.com/)
[](https://auth0.com/)
[](https://nodejs.org/)
[](https://modelcontextprotocol.io/)
---
## What this is
The [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) 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](https://github.com/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](https://dash.cloudflare.com/sign-up)** — Workers' free tier (100k requests/day) is enough for this whole tutorial
- A free **[Auth0 account](https://auth0.com/signup)**
- `curl` (ships with Windows 10+ as `curl.exe`) or PowerShell's `Invoke-RestMethod`
- The [MCP Inspector](https://github.com/modelcontextprotocol/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](#powershell-cheat-sheet) below before you start pasting commands.
---
## Part 1 — Secure REST API (`todos-api`)
This is a small [Hono](https://hono.dev/) 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
```powershell
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
```powershell
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.
```powershell
$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
```powershell
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
```powershell
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:
```powershell
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
```powershell
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:
```powershell
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
```powershell
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:
```powershell
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:
```powershell
# 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
```powershell
# 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
```powershell
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:
```powershell
$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](https://github.com/cloudflare/ai) — `demos/remote-mcp-auth0`
- Walkthrough reference: [Secure and Deploy Remote MCP Servers with Auth0 and Cloudflare](https://auth0.com/blog/secure-and-deploy-remote-mcp-servers-with-auth0-and-cloudflare/) (Auth0 blog)
- [Model Context Protocol](https://modelcontextprotocol.io/) specification and [Inspector](https://github.com/modelcontextprotocol/inspector)
## License
[MIT](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>
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues