Skip to main content
Glama
mohamedelamraoui1

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.

[![Follow on X](https://img.shields.io/badge/-Follow%20%40mcoding__off-black?style=flat&logo=x)](https://x.com/mcoding_off)
[![WhatsApp Channel](https://img.shields.io/badge/WhatsApp-Channel-25D366?style=flat&logo=whatsapp&logoColor=white)](https://whatsapp.com/channel/0029Vb7WRtT11ulGgJPp4m3y)
[![Reddit](https://img.shields.io/badge/Reddit-u%2Fmcoding__off-FF4500?style=flat&logo=reddit&logoColor=white)](https://www.reddit.com/user/mcoding_off/)
[![License](https://img.shields.io/badge/License-MIT-blue?style=flat)](LICENSE)

[![Python](https://img.shields.io/badge/Python-3.12+-3776AB?style=flat&logo=python&logoColor=white)](https://www.python.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.x-3178C6?style=flat&logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
[![Cloudflare Workers](https://img.shields.io/badge/Cloudflare-Workers-F38020?style=flat&logo=cloudflare&logoColor=white)](https://workers.cloudflare.com/)
[![Auth0](https://img.shields.io/badge/Auth0-OAuth%202.0%2FOIDC-EB5424?style=flat&logo=auth0&logoColor=white)](https://auth0.com/)
[![Node.js](https://img.shields.io/badge/Node.js-18+-339933?style=flat&logo=node.js&logoColor=white)](https://nodejs.org/)
[![MCP](https://img.shields.io/badge/Model%20Context%20Protocol-000000?style=flat)](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>