> ## 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">
    Add `auth` to every tool entry you want gated in `ai/mcp_server/<server>.xs`, setting it
    to the name of your auth-enabled table — usually `user` — then `xano workspace push`:

    ```xs ai/mcp_server/support.xs theme={null}
    mcp_server "Customer Support" {
      canonical = "support-mcp-v1"
      tools = [
        {name: "get_order_status", auth: "user"}
        {name: "update_order", auth: "user"}
      ]
    }
    ```

    Or set it by hand: open your MCP server → **Connected Tools** → set the
    **Authentication** column to **user authentication** on every tool you want gated.

    Either way, 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 `auth` field 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.

    It is also per tool *per server*. `auth` lives on the entry in the MCP server file, not
    on the tool in `ai/tool/`, so a tool exposed by two servers needs the field on both.
  </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 entry is still missing `auth`.
  </Step>
</Steps>

<Warning>
  A tool entry with no `auth` is callable by anyone who reaches your Xano MCP URL
  directly — the Worker is not in that path. Gate every tool, on every server that
  exposes it.
</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 entry is missing `auth`, or a second MCP server exposes the same tool without it. 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)
