> ## Documentation Index
> Fetch the complete documentation index at: https://docs.xano.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Build the OAuth Worker by hand

> The step-by-step build for the Cloudflare Worker that fronts your Xano MCP server with OAuth 2.1 — spec requirements, the four Worker files, testing with the MCP Inspector, and a troubleshooting table.

<Info>
  **This is the by-hand path.** It's also the reference for checking an agent's work — every
  command and expected response is spelled out, so you can compare against what the agent
  reported.

  If you'd rather hand the mechanical parts to a coding agent, the prompt cards are on
  [Add OAuth to a Xano MCP server](/ai-tools/mcp-builder/oauth-proxy), along with the flow
  diagram and the "do I even need OAuth?" table. Start there — this page assumes you've
  already decided you do.
</Info>

## What the spec requires

<Warning>
  **A token in the URL won't work on Claude surfaces.** Add a connector URL with the
  token in it and Claude sends you to an OAuth consent page that can't complete — it
  runs an OAuth handshake for every custom connector, and a Xano MCP server has no
  authorization server to hand it off to. Since the `2025-06-18` revision an MCP
  server is an OAuth **resource server**: clients discover the authorization server
  via `/.well-known/oauth-protected-resource` (RFC 9728), and the spec **explicitly
  prohibits access tokens in the URI query string**. The Worker in this guide is the
  shim that fills that gap.

  What Claude enforces today, per [Authentication for
  connectors](https://claude.com/docs/connectors/building/authentication):

  * **A `401` — not a `200`.** Claude does not honor a `WWW-Authenticate` header on
    a `200` response. The challenge must carry `resource_metadata` pointing at your
    protected resource metadata document.
  * **RFC 9728 probing.** With no explicit `resource_metadata` pointer, Claude tries
    `/.well-known/oauth-protected-resource/<your-mcp-path>` first, then
    `/.well-known/oauth-protected-resource`.
  * **PKCE, always.** Claude sends a `code_challenge` with
    `code_challenge_method=S256` on every authorization request.
  * **DCR is the slow path.** Dynamic client registration still works, but Claude
    registers a new client on every fresh connection; CIMD is preferred for
    high-traffic servers.

  `OAuthProvider` satisfies all four out of the box — Step 4's `curl` checks confirm
  it on your deployment.
</Warning>

<Note>
  **MCP `2026-07-28` is the current revision.** Published on 28 July 2026, it makes
  the protocol stateless and is
  [rolling out across Claude products](https://claude.com/blog/bringing-mcp-2026-07-28-to-claude).
  Three changes touch this guide, so track them rather than treating this page as
  final:

  * **`Mcp-Session-Id` is gone**, along with the `initialize` handshake. The proxy's
    session round-trip becomes a no-op for `2026-07-28` clients (harmless), but any
    Xano-side logic keyed to a session must move to explicit tool arguments.
  * **`Last-Event-ID` and SSE resumability are removed.** A broken stream is
    re-issued as a new request instead of resumed.
  * **DCR is deprecated in favor of Client ID Metadata Documents (CIMD).** Dynamic
    client registration has been demoted across three revisions: a `SHOULD` in
    `2025-06-18`, downgraded to a `MAY` in `2025-11-25` when CIMD was introduced, and
    deprecated in `2026-07-28`. It keeps working for backward compatibility — the
    spec's deprecation window is a minimum of twelve months, and Claude still supports
    it — but new deployments should plan for CIMD.

  Everything else on this page — RFC 9728 discovery, the `401` challenge, PKCE,
  header-only tokens — is unchanged by `2026-07-28`.
</Note>

## Step 1 — Turn on user authentication in Xano

<Steps>
  <Step title="Set each tool's Authentication to user authentication">
    Open your MCP server → **Connected Tools** → set the **Authentication** column to
    **user authentication** on every tool you want gated.

    Xano then validates `Authorization: Bearer <user JWT>` natively and populates
    `$auth`. No custom token-checking function is needed.
  </Step>

  <Step title="Retire any auth checks you wrote yourself">
    Skip this if your tools had no authentication before — the toggle is all you need.

    Otherwise, native auth replaces whatever you built, and leaving it in place can
    reject the users you're onboarding. Check three places, not just the stack:

    * **The tool's stack** — a `function.run` call to a custom token-validation
      function, a token read by hand from an input or header, or a `precondition`
      gating on a shared secret.
    * **Middleware** — [middleware](/building/logic/middleware) can be applied to AI
      tools, so the check may live there rather than in the stack.
    * **An MCP server trigger** — a [connection
      trigger](/building/logic/triggers/mcp-servers) that validates `toolset.token`.

    Remove only the parts that authenticate. A trigger or middleware often does other
    work too — filtering which tools a client sees, logging, rate limiting — and that
    should stay. If any tool's logic uses the old token value downstream, swapping it
    for `$auth` changes behavior; decide that case deliberately.
  </Step>

  <Step title="Know what the toggle does and does not gate">
    This setting gates tool **calls** only. An unauthenticated `initialize` still
    returns `200`. **To test whether auth is on, call a tool** — a successful
    `initialize` proves nothing.

    The setting lives in the dashboard, is not represented in XanoScript, and survives
    `xano workspace push` of your tool files.
  </Step>

  <Step title="Verify auth is enforced">
    Call a **read-only** tool against the stream URL with no `Authorization` header.
    Expected response:

    ```json theme={null}
    {"code":"ERROR_CODE_UNAUTHORIZED","message":"Unauthorized - Authentication Required"}
    ```

    If you get real data instead, that tool's Authentication is still **Disabled**.
  </Step>
</Steps>

<Warning>
  Any tool left **Disabled** is callable by anyone who reaches your Xano MCP URL
  directly — the Worker is not in that path. Gate every tool.
</Warning>

## Step 2 — Collect your two URLs

Both are covered on the [parent page](/ai-tools/mcp-builder/oauth-proxy#what-you-need),
including where to find them in the dashboard or from the CLI. The shapes, for reference:

<CodeGroup>
  ```text XANO_MCP_STREAM_URL theme={null}
  https://{instance-host}/x2/mcp/{canonical}/mcp/stream
  ```

  ```text XANO_AUTH_BASE theme={null}
  https://{instance-host}/api:{auth-group-canonical}
  ```
</CodeGroup>

<Note>
  Use the **streaming** endpoint. SSE-only endpoints are deprecated and being sunset;
  new integrations should use streaming exclusively.
</Note>

## Step 3 — Deploy the Worker

The Worker is four small files. `@cloudflare/workers-oauth-provider` does all the
OAuth heavy lifting: metadata documents, `/token`, `/register`, PKCE enforcement,
token issuance and rotation, and the 401 challenge.

<CodeGroup>
  ```ts src/index.ts theme={null}
  export default new OAuthProvider<Env>({
    apiRoute: "/mcp",                 // requests here require a valid token
    apiHandler: XanoProxy,            // runs only after token validation
    defaultHandler: authorizeHandler, // /authorize, /login, /icon.png, /
    authorizeEndpoint: "/authorize",
    tokenEndpoint: "/token",
    clientRegistrationEndpoint: "/register",
    scopesSupported: ["mcp"],
    resourceMetadata: {
      resource_name: "My Xano MCP",   // display name shown to clients
      scopes_supported: ["mcp"],
    },
    resourceMatchOriginOnly: true,
    accessTokenTTL: 3600,
  });
  ```

  ```ts src/authorize.ts (behavior) theme={null}
  // GET /authorize  → parseAuthRequest + lookupClient, render the login form,
  //                   carrying the auth request in an HMAC-signed state
  //                   (COOKIE_SECRET, 10-minute expiry).
  // POST /login     → forward { email, password } to ${XANO_AUTH_BASE}/auth/login
  //                   over HTTPS, then:
  await completeAuthorization({
    request: authRequest,
    userId: email,
    metadata: { label: email },
    scope: ["mcp"],
    props: { userEmail: email, xanoJWT: authToken }, // encrypted at rest in KV
  });
  // GET /           → HTML with <link rel="icon" href="/icon.png"> (see Icons)
  ```

  ```ts src/proxy.ts (behavior) theme={null}
  // Reads props.xanoJWT from the validated grant, then:
  //  - forwards content-type, accept, mcp-session-id, mcp-protocol-version,
  //    last-event-id
  //  - DROPS the client's Authorization header
  //  - SETS Authorization: Bearer <xanoJWT>
  //  - returns upstream.body directly — never awaits the body (SSE-safe)
  //  - round-trips Mcp-Session-Id in both directions
  ```

  ```jsonc wrangler.jsonc theme={null}
  {
    "name": "xano-mcp-oauth",
    "main": "src/index.ts",
    "compatibility_date": "2025-07-01",
    "kv_namespaces": [{ "binding": "OAUTH_KV", "id": "REPLACE_WITH_KV_NAMESPACE_ID" }],
    "observability": { "enabled": true }
  }
  ```
</CodeGroup>

<Steps>
  <Step title="Authenticate and create KV storage">
    ```bash theme={null}
    npm install
    npx wrangler login
    npx wrangler kv namespace create OAUTH_KV   # paste the id into wrangler.jsonc
    ```
  </Step>

  <Step title="Deploy to learn your Worker host">
    ```bash theme={null}
    npx wrangler deploy
    # → https://xano-mcp-oauth.<subdomain>.workers.dev
    ```
  </Step>

  <Step title="Set three secrets">
    ```bash theme={null}
    printf '%s' "https://{instance-host}/x2/mcp/{canonical}/mcp/stream" \
      | npx wrangler secret put XANO_MCP_STREAM_URL
    printf '%s' "https://{instance-host}/api:{auth-group}" \
      | npx wrangler secret put XANO_AUTH_BASE
    printf '%s' "$(python3 -c 'import secrets;print(secrets.token_hex(32))')" \
      | npx wrangler secret put COOKIE_SECRET

    npx wrangler secret list
    npx wrangler deploy
    ```

    Everything instance-specific lives in secrets, not code — the same Worker fronts a
    different Xano MCP by changing these two URLs.
  </Step>
</Steps>

<Warning>
  **Handling credentials.** In this flow the Worker receives the user's Xano password
  in order to exchange it at `/auth/login`. Before using it beyond your own testing:

  * Serve the login page over HTTPS only, accept credentials by `POST` only, and never
    place them in a query string.
  * Never log the request body, and never persist the password — forward it and drop it.
  * Rotate `COOKIE_SECRET` if it is ever exposed; it signs the OAuth state.
  * Prefer a passwordless variant where you can: swap `/auth/login` for
    `/auth/magic_link` or your IdP's `/auth/*` endpoint. The only contract the Worker
    needs is to end with a valid Xano user JWT in `props.xanoJWT`.
  * Have Security review this before exposing it to users other than yourself.
</Warning>

## Step 4 — Test with the MCP Inspector (optional)

Skip to Step 5 if you'd rather go straight at it with the real client. The
Inspector is worth the five minutes, though: it separates "the Worker is broken"
from "the client is misconfigured," which is otherwise hard to tell apart.

Either way, `initialize` succeeding is not a passing test — a tool call returning
rows is.

<Steps>
  <Step title="Check discovery and the 401 challenge">
    ```bash theme={null}
    H="https://xano-mcp-oauth.<subdomain>.workers.dev"

    curl -i "$H/mcp"                                      # 401 + WWW-Authenticate
    curl "$H/.well-known/oauth-authorization-server"      # S256 + /register present
    curl "$H/.well-known/oauth-protected-resource/mcp"    # bearer_methods_supported: ["header"]
    ```

    The 401's `WWW-Authenticate` header must carry `resource_metadata=…`. That header is
    what tells an OAuth client where to go next; without it the client just fails.
  </Step>

  <Step title="Open a log tail in one terminal">
    ```bash theme={null}
    npx wrangler tail --format pretty
    ```
  </Step>

  <Step title="Run the Inspector in another">
    ```bash theme={null}
    npx @modelcontextprotocol/inspector
    ```

    In the Inspector UI:

    1. **Transport Type** → `Streamable HTTP`
    2. **URL** → `https://xano-mcp-oauth.<subdomain>.workers.dev/mcp`
    3. **Authentication** → `OAuth`
    4. **Connect** → you are redirected to the Worker login page; sign in with a Xano
       user-table account.
    5. **List Tools** → run a **read-only** tool.
  </Step>

  <Step title="Read the tail">
    A healthy run looks like this:

    ```
    GET  /mcp                  → 401
    POST /register             → dynamic client registration
    GET  /authorize            → login page
    POST /login                → [login] xano /auth/login -> 200
                                 [login] authorized you@example.com
    POST /token                → token issued
    POST /mcp (initialize)     → [proxy] user=you@example.com -> xano 200 session=<id>
    POST /mcp (tools/call)     → [proxy] user=you@example.com -> xano 200
    ```

    `Tool Result: Success` with real rows means the request reached Xano carrying your
    per-user JWT. That is the pass condition.
  </Step>
</Steps>

## Step 5 — Connect a client

Add a custom connector pointing at:

```
https://xano-mcp-oauth.<subdomain>.workers.dev/mcp
```

No token, no query string. The client runs the registration → login → PKCE → token
handshake itself, then lists and calls your tools. If it fails here and you skipped
Step 4, run the Inspector now — it will tell you which side is at fault.

## Troubleshooting

Search this table for the literal error text you're seeing.

| What you see                                                                                                          | Cause and fix                                                                                                                                                   |
| --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `{"code":"ERROR_CODE_UNAUTHORIZED","message":"Unauthorized - Authentication Required"}` after connecting successfully | The Xano JWT expired (24h default). Log in again. If it fails immediately, the JWT isn't reaching Xano — check `[proxy] … -> xano <status>` in `wrangler tail`. |
| A tool runs with **no** auth at all                                                                                   | That tool's Authentication is still **Disabled** in Connected Tools. See Step 1.                                                                                |
| `invalid content type for SSE endpoint`                                                                               | The request is missing `Accept: application/json, text/event-stream`, or is pointed at a deprecated SSE endpoint instead of `/mcp/stream`.                      |
| `404` from Xano                                                                                                       | Wrong `XANO_MCP_STREAM_URL`. The shape is `/x2/mcp/{canonical}/mcp/stream` — `mcp` twice.                                                                       |
| Inspector connects, then hangs after `initialize`                                                                     | `Mcp-Session-Id` isn't being round-tripped. The proxy must return it on the response and forward it on subsequent requests.                                     |
| Streamed output arrives all at once, or stalls                                                                        | The response body is being buffered. Return `upstream.body` directly; never `await upstream.text()`.                                                            |
| Client never prompts for login, just errors                                                                           | The 401 is missing `WWW-Authenticate: … resource_metadata=…`, or `/.well-known/oauth-protected-resource` isn't reachable.                                       |
| `curl` to `/register` returns 403 but a browser works                                                                 | Cloudflare edge bot filtering on the tool's user-agent. Pass a normal `User-Agent`. Not a Worker bug.                                                           |
| `[login] xano /auth/login -> 4xx`                                                                                     | Xano rejected the credentials or `XANO_AUTH_BASE` is wrong. Confirm the endpoint returns `{ authToken }`.                                                       |

Useful commands:

```bash theme={null}
npx wrangler tail --format pretty           # live logs
npx wrangler secret list                    # which secrets are set
npx wrangler kv key list --binding OAUTH_KV # stored clients, grants, tokens
```

<Tip>
  There's a prompt card for this — [Diagnose a failing
  proxy](/ai-tools/mcp-builder/oauth-proxy#when-it-breaks-and-what-to-fix-later) hands the
  whole table to an agent along with your symptom, and has it gather the evidence first.
</Tip>

## Icons and display name

There is no icon setting on Cloudflare — clients resolve it differently:

* **Claude surfaces** fetch the connector origin root (`/`) as HTML and parse
  `<link rel="icon">`. Serve HTML at `/` with a PNG icon link.
* **MCP Inspector** reads `serverInfo.icons` from the `initialize` response.

The display name comes from `resourceMetadata.resource_name` in `src/index.ts`.

<Note>
  Connector icons are cached per domain and can be sticky — a removed and re-added
  connector may keep showing the fallback avatar. A fresh hostname (rename the Worker
  or bind a custom domain) forces a cold fetch.
</Note>

## Limitations

* **24-hour JWT.** The Xano user JWT expires in 24h while the OAuth grant lasts
  longer, so upstream calls start 401ing and the user re-logs-in. To smooth this
  out, add a Xano `POST /auth/refresh` (`auth="user"`) and call it from
  `OAuthProvider`'s `tokenExchangeCallback`, writing the fresh JWT into `newProps`.
* **No consent screen as built.** This treats a successful login as consent. A
  public or multi-tenant deployment needs an explicit consent step with CSRF
  protection in `/authorize` before `completeAuthorization` — there's a prompt for
  that on [Add a consent
  screen](/ai-tools/mcp-builder/oauth-proxy#add-a-consent-screen-optional).
* **Gate every tool.** Only tools set to user authentication are protected.
* **One MCP server per Worker** as written. To front several, select
  `XANO_MCP_STREAM_URL` by request path and use `apiHandlers` (a route → handler
  map) instead of a single `apiHandler`.

## Next steps

<CardGroup cols={2}>
  <Card title="Add OAuth to a Xano MCP server" href="/ai-tools/mcp-builder/oauth-proxy">
    The overview, the flow diagram, and the prompt cards that do all of this for you.
  </Card>

  <Card title="Connecting Clients" href="/ai-tools/mcp-builder/connecting-clients">
    Header-based and URL-based auth for clients that don't need OAuth.
  </Card>
</CardGroup>


## Related topics

- [Add OAuth to a Xano MCP server](/ai-tools/mcp-builder/oauth-proxy.md)
- [Connecting Clients](/ai-tools/mcp-builder/connecting-clients.md)
- [MCP Builder](/ai-tools/mcp-builder.md)
