> ## 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.

# Add OAuth to a Xano MCP server

> Put a spec-compliant OAuth 2.1 layer in front of your Xano MCP server with a Cloudflare Worker, so OAuth-only clients connect to a plain /mcp URL with no token in the URL and real per-user identity.

export const HandNote = props => {
  const {children, align = "right", className = ""} = props || ({});
  return <div className={`hand-note hand-note-${align} ${className}`.trim()}>
      <span className="hand-note-text">{children}</span>
      {}
      <svg className="hand-note-arrow" viewBox="0 0 80 120" fill="none" aria-hidden="true">
        <path d="M6 8C42 12 76 34 70 68 66 90 56 102 44 110" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
        <path d="M58 109 L44 110 L50 97" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
      </svg>
    </div>;
};

export const OAuthFlow = ({title = "Flow map", subtitle = "the OAuth handshake at a glance — hover a step to isolate it"}) => {
  const GROUPS = {
    discovery: "Discovery",
    auth: "Authorization",
    token: "Token",
    proxy: "Proxied MCP traffic"
  };
  const LANES = ["Client", "Cloudflare Worker", "Xano"];
  const STEPS = [{
    g: "discovery",
    from: {
      n: "Client",
      t: "no token"
    },
    chip: "GET",
    path: "/mcp",
    desc: "unauthenticated probe",
    to: {
      n: "401",
      t: "WWW-Authenticate"
    }
  }, {
    g: "discovery",
    from: {
      n: "Client",
      t: "discovery"
    },
    chip: "GET",
    path: "/.well-known/*",
    desc: "RFC 9728 + RFC 8414 metadata",
    to: {
      n: "Worker",
      t: "library"
    }
  }, {
    g: "discovery",
    from: {
      n: "Client",
      t: "registration"
    },
    chip: "POST",
    path: "/register",
    desc: "dynamic client registration",
    to: {
      n: "Worker",
      t: "library"
    }
  }, {
    g: "auth",
    from: {
      n: "Client",
      t: "pkce"
    },
    chip: "GET",
    path: "/authorize",
    desc: "code_challenge S256 → login page",
    to: {
      n: "Worker",
      t: "login page"
    }
  }, {
    g: "auth",
    from: {
      n: "Worker",
      t: "credentials"
    },
    chip: "POST",
    path: "/auth/login",
    desc: "email + password forwarded over HTTPS",
    to: {
      n: "Xano",
      t: "{ authToken }"
    }
  }, {
    g: "auth",
    from: {
      n: "Worker",
      t: "grant"
    },
    chip: "KV",
    path: "props.xanoJWT",
    desc: "authToken stored as encrypted grant props",
    to: {
      n: "OAUTH_KV",
      t: "encrypted"
    }
  }, {
    g: "token",
    from: {
      n: "Client",
      t: "verifier"
    },
    chip: "POST",
    path: "/token",
    desc: "PKCE verifier exchanged for an access token",
    to: {
      n: "Worker",
      t: "bearer"
    }
  }, {
    g: "proxy",
    from: {
      n: "Client",
      t: "bearer"
    },
    chip: "POST",
    path: "/mcp",
    desc: "Worker token validated, then dropped",
    to: {
      n: "Proxy",
      t: "authorized"
    }
  }, {
    g: "proxy",
    from: {
      n: "Worker",
      t: "swap"
    },
    chip: "POST",
    path: "/x2/mcp/{canonical}/mcp/stream",
    desc: "swaps in Bearer <xanoJWT>",
    to: {
      n: "Xano",
      t: "native mcp"
    }
  }, {
    g: "proxy",
    from: {
      n: "Xano",
      t: "response"
    },
    chip: "200",
    path: "text/event-stream",
    desc: "JSON-RPC streamed back — single responses use application/json",
    to: {
      n: "Client",
      t: "streamed"
    }
  }];
  const CSS = `
.xflow{
  --xf-bg:#fcfcfd;--xf-panel:#fff;--xf-line:rgba(0,0,0,.09);--xf-dot:rgba(0,0,0,.055);
  --xf-fg:#18181b;--xf-fg2:#52525b;--xf-fg3:#71717a;--xf-fg4:#a1a1aa;
  --xf-tint:rgba(0,0,0,.018);--xf-dim:.34;
  --xf-chip-ink:var(--xf-c);--xf-chip-bg:color-mix(in srgb,var(--xf-c) 11%,transparent);
  --xf-mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,monospace;
  margin:20px 0;border:1px solid var(--xf-line);border-radius:14px;background:var(--xf-bg);
  background-image:radial-gradient(circle at 1px 1px,var(--xf-dot) 1px,transparent 0);
  background-size:22px 22px;overflow:hidden;color:var(--xf-fg);
  font-size:13.5px;line-height:1.5;-webkit-font-smoothing:antialiased}
html.dark .xflow{
  --xf-bg:#0d0d11;--xf-panel:#101015;--xf-line:rgba(255,255,255,.07);--xf-dot:rgba(255,255,255,.05);
  --xf-fg:#f4f4f6;--xf-fg2:#a1a1aa;--xf-fg3:#8a8a93;--xf-fg4:#71717a;
  --xf-tint:rgba(255,255,255,.012);--xf-dim:.3;
  --xf-chip-ink:color-mix(in srgb,var(--xf-c) 78%,#fff);
  --xf-chip-bg:color-mix(in srgb,var(--xf-c) 13%,transparent)}
.xflow *{box-sizing:border-box}

/* Group accents. Darkened for light mode so they stay legible on a pale surface. */
.xflow-row[data-g=discovery],.xflow-leg[data-g=discovery]{--xf-c:#0284c7}
.xflow-row[data-g=auth],.xflow-leg[data-g=auth]{--xf-c:#b45309}
.xflow-row[data-g=token],.xflow-leg[data-g=token]{--xf-c:#7c3aed}
.xflow-row[data-g=proxy],.xflow-leg[data-g=proxy]{--xf-c:#15803d}
html.dark .xflow-row[data-g=discovery],html.dark .xflow-leg[data-g=discovery]{--xf-c:#7dd3fc}
html.dark .xflow-row[data-g=auth],html.dark .xflow-leg[data-g=auth]{--xf-c:#f5b544}
html.dark .xflow-row[data-g=token],html.dark .xflow-leg[data-g=token]{--xf-c:#a78bfa}
html.dark .xflow-row[data-g=proxy],html.dark .xflow-leg[data-g=proxy]{--xf-c:#4ade80}

.xflow-head{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;
  padding:16px 20px 14px;border-bottom:1px solid var(--xf-line)}
.xflow-title{font-size:14px;font-weight:600;color:var(--xf-fg);letter-spacing:-.01em}
.xflow-sub{font-size:12.5px;color:var(--xf-fg3)}

.xflow-lanes{display:flex;align-items:center;gap:8px;flex-wrap:wrap;
  padding:12px 20px;border-bottom:1px solid var(--xf-line);background:var(--xf-tint)}
.xflow-lane{font-family:var(--xf-mono);font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;
  color:var(--xf-fg3);border:1px solid var(--xf-line);border-radius:999px;padding:3px 10px;white-space:nowrap}
.xflow-lane-sep{color:var(--xf-fg4);font-size:11px}

.xflow-rows{padding:6px 0;margin:0;list-style:none}
.xflow-row{display:grid;grid-template-columns:180px 22px 1fr auto;align-items:center;gap:0;
  padding:9px 20px 9px 0;position:relative;border-left:2px solid transparent;
  transition:opacity .18s ease,background-color .18s ease,border-color .18s ease}
.xflow.is-linking .xflow-row{opacity:var(--xf-dim)}
.xflow.is-linking .xflow-row.is-lit{opacity:1;background:color-mix(in srgb,var(--xf-c) 7%,transparent);
  border-left-color:var(--xf-c)}

.xflow-src{padding-left:18px;min-width:0}
.xflow-pill{display:inline-flex;align-items:center;gap:7px;max-width:100%;
  border:1px solid color-mix(in srgb,var(--xf-c) 30%,var(--xf-line));border-radius:9px;
  padding:6px 11px;background:var(--xf-panel);transition:border-color .18s ease,box-shadow .18s ease}
.xflow-row.is-lit .xflow-pill{border-color:var(--xf-c);
  box-shadow:0 0 0 1px color-mix(in srgb,var(--xf-c) 25%,transparent),
             0 3px 14px -4px color-mix(in srgb,var(--xf-c) 55%,transparent)}
.xflow-dot{width:6px;height:6px;border-radius:50%;background:var(--xf-c);flex:none;opacity:.75;
  transition:opacity .18s ease}
.xflow-row.is-lit .xflow-dot{opacity:1}
.xflow-pill-n{font-size:12.5px;font-weight:600;color:var(--xf-fg);white-space:nowrap}
.xflow-pill-t{font-family:var(--xf-mono);font-size:9.5px;letter-spacing:.06em;text-transform:uppercase;
  color:var(--xf-fg4);white-space:nowrap}

.xflow-spine{display:flex;align-items:center;justify-content:center;align-self:stretch;position:relative}
.xflow-spine::before{content:"";position:absolute;top:-9px;bottom:-9px;width:1px;background:var(--xf-line)}
.xflow-knot{width:9px;height:9px;border-radius:50%;background:var(--xf-bg);
  border:1.5px solid var(--xf-fg4);position:relative;z-index:1;transition:all .18s ease}
.xflow-row.is-lit .xflow-knot{background:var(--xf-c);border-color:var(--xf-c);
  box-shadow:0 0 10px 1px color-mix(in srgb,var(--xf-c) 60%,transparent)}

.xflow-body{display:flex;align-items:baseline;gap:9px;flex-wrap:wrap;padding:0 16px;min-width:0}
.xflow-chip{font-family:var(--xf-mono);font-size:9.5px;font-weight:700;letter-spacing:.07em;
  color:var(--xf-chip-ink);background:var(--xf-chip-bg);
  border-radius:5px;padding:3px 7px;flex:none}
.xflow-path{font-family:var(--xf-mono);font-size:12.5px;color:var(--xf-fg);word-break:break-all}
.xflow-desc{font-size:12.5px;color:var(--xf-fg3)}
.xflow-row.is-lit .xflow-desc{color:var(--xf-fg2)}

.xflow-dst{display:flex;align-items:center;gap:9px;justify-self:end}
.xflow-arrow{color:var(--xf-fg4);font-size:9px;flex:none;transition:color .18s ease}
.xflow-row.is-lit .xflow-arrow{color:var(--xf-c)}

.xflow-legend{display:flex;flex-wrap:wrap;gap:8px 22px;padding:13px 20px;
  border-top:1px solid var(--xf-line);background:var(--xf-tint)}
.xflow-leg{display:inline-flex;align-items:center;gap:7px;font-size:11.5px;color:var(--xf-fg3)}
.xflow-leg-sw{width:7px;height:7px;border-radius:50%;flex:none;background:var(--xf-c)}

@media (max-width:780px){
  .xflow-row{grid-template-columns:1fr;gap:8px;padding:12px 18px}
  .xflow-spine{display:none}
  .xflow-src{padding-left:0}
  .xflow-body{padding:0}
  .xflow-dst{justify-self:start}
}
@media (prefers-reduced-motion:reduce){
  .xflow-row,.xflow-pill,.xflow-knot,.xflow-arrow,.xflow-dot,.xflow-desc{transition:none}
}
`;
  const [lit, setLit] = useState(null);
  useEffect(() => {
    if (typeof document === "undefined") return;
    if (document.getElementById("xflow-styles")) return;
    const el = document.createElement("style");
    el.id = "xflow-styles";
    el.textContent = CSS;
    document.head.appendChild(el);
  }, []);
  const pill = p => <span className="xflow-pill">
      <span className="xflow-dot" />
      <span className="xflow-pill-n">{p.n}</span>
      {p.t ? <span className="xflow-pill-t">{p.t}</span> : null}
    </span>;
  return <div className={`xflow${lit !== null ? " is-linking" : ""}`}>
      <div className="xflow-head">
        <span className="xflow-title">{title}</span>
        <span className="xflow-sub">{subtitle}</span>
      </div>

      <div className="xflow-lanes">
        {LANES.map((l, i) => [i > 0 ? <span className="xflow-lane-sep" key={`s${i}`} aria-hidden="true">
              →
            </span> : null, <span className="xflow-lane" key={l}>
            {l}
          </span>])}
      </div>

      <ol className="xflow-rows">
        {STEPS.map((s, i) => <li key={i} className={`xflow-row${lit === i ? " is-lit" : ""}`} data-g={s.g} tabIndex={0} onMouseEnter={() => setLit(i)} onMouseLeave={() => setLit(null)} onFocus={() => setLit(i)} onBlur={() => setLit(null)}>
            <div className="xflow-src">{pill(s.from)}</div>
            <div className="xflow-spine" aria-hidden="true">
              <span className="xflow-knot" />
            </div>
            <div className="xflow-body">
              <span className="xflow-chip">{s.chip}</span>
              <code className="xflow-path">{s.path}</code>
              <span className="xflow-desc">{s.desc}</span>
            </div>
            <div className="xflow-dst">
              <span className="xflow-arrow" aria-hidden="true">
                ▶
              </span>
              {pill(s.to)}
            </div>
          </li>)}
      </ol>

      <div className="xflow-legend">
        {Object.keys(GROUPS).map(k => <span className="xflow-leg" data-g={k} key={k}>
            <span className="xflow-leg-sw" />
            {GROUPS[k]}
          </span>)}
      </div>
    </div>;
};

By the end of this page, Claude Web, Claude Desktop, or any other OAuth-only client will
connect to your Xano MCP server through a plain `/mcp` URL — no token in the URL, and
every request carrying the signed-in user's own identity.

Getting there is mostly mechanical: four small files, a few `wrangler` commands, and
some log-reading. So you don't have to do it by hand. Each section hands its work to
a coding agent as a ready-made prompt — you supply two URLs and a couple of clicks in
Xano, and the agent does the rest.

Everything between here and there is context: who this is for, how the pieces fit, and
the two URLs to have ready. **[Skip to the first prompt](#in-xano-%E2%80%94-gate-your-tools)**
if you'd rather start building and backfill the reasoning later.

<Tip>
  Rather build it yourself, or want something to check the agent's work against? Every
  command and expected response is written out in [Manual
  setup](/ai-tools/mcp-builder/oauth-proxy/manual-setup).
</Tip>

## Who is this for?

Two things decide it: what your client can send, and whether you need each user to arrive
as themselves rather than as one shared token. Your Xano server doesn't come into it.

| Your client                                                                                                               | What it can send                                                                                                                                                                           | This page?                                                                                                    |
| ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
| Claude web, Claude mobile, Cowork                                                                                         | OAuth only — no header field, and a token in the URL sends you to a consent page that can't complete                                                                                       | **Yes**                                                                                                       |
| ChatGPT (developer mode), Microsoft 365 Copilot MCP plugins                                                               | OAuth only — ChatGPT explicitly "cannot present custom API keys"                                                                                                                           | **Yes**                                                                                                       |
| Any client, when you need per-user identity                                                                               | —                                                                                                                                                                                          | **Yes**                                                                                                       |
| Claude Desktop                                                                                                            | Either. Its **Add custom connector** dialog is OAuth-only, but `claude_desktop_config.json` can carry a header via [`mcp-remote`](/ai-tools/mcp-builder/connecting-clients#claude-desktop) | Only for per-user identity                                                                                    |
| Claude Code, Cursor, VS Code, Antigravity, Windsurf, Zed, Warp, Raycast, Cline, Roo Code, Continue, Gemini CLI, Codex CLI | One shared bearer token in the `Authorization` header — never per-user identity                                                                                                            | No, for you or your team — [native header auth](/ai-tools/mcp-builder/connecting-clients) takes a few minutes |

<Warning>
  **A header token is shared, not per-user.** Every client configured with it sends the same
  credential, so Xano sees one identity no matter who is actually asking. `$auth` can't tell
  your users apart, tools can't scope data to the caller, and revoking access for one person
  means rotating the token for everyone who has it.

  That's a reasonable basic gate for you and your team on your own machines. It stops being
  one the moment you ship an MCP server to people who should each see only their own data, or
  you want the agent to have context about *which* user it's acting for. That's what the
  OAuth path on this page buys you — no matter what your client is capable of sending.
</Warning>

Xano MCP servers don't expose an OAuth **authorization server** — there's no `/authorize`,
no `/token`, and no discovery document for a client to find. What they do have is
**per-tool authentication**: set a tool's **Authentication** to **user authentication** and
Xano validates a Xano user-table JWT on the `Authorization` header natively, populating
`$auth`. That's the mechanism this whole page is built on.

Xano issues those JWTs perfectly well — that's `security.create_auth_token` behind your own
`/auth/login`. They just aren't reachable through an OAuth handshake. The Worker supplies
that missing front end: it speaks OAuth to the client, exchanges the sign-in for a Xano user
JWT, and forwards that upstream.

**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. The MCP spec also prohibits access tokens in the URI query string, and has
since the `2025-06-18` revision; the current `2026-07-28` revision keeps that ban. Either
way, a token in a URL is one shared credential that lands in logs, proxies, and browser
history.

<Accordion title="What about the Request headers field in Anthropic's docs?">
  Anthropic's connector docs describe a **Request headers** section for entering a fixed API
  key or bearer token. It's a gated beta, and it isn't in the Add custom connector dialog most
  accounts see. Even with it enabled, the credential is entered once by an org administrator
  and shared by everyone — a broad multi-user gate, not per-user identity. Anthropic's own
  guidance: "If each person needs to sign in with their own account, use OAuth instead."
</Accordion>

<Accordion title="The spec details, if you want them">
  The full picture — RFC 9728 discovery, why the challenge must be a `401` and not a `200`,
  PKCE, DCR versus CIMD, and what the current MCP `2026-07-28` revision changed, including DCR
  being deprecated in favor of Client ID Metadata Documents — is in
  [Manual setup](/ai-tools/mcp-builder/oauth-proxy/manual-setup#what-the-spec-requires).

  You don't need any of it to follow this page. `@cloudflare/workers-oauth-provider` satisfies
  all of it out of the box, and the verification prompt below confirms it on your deployment.
</Accordion>

## How it works

<OAuthFlow />

Two hops, no URL tokens:

* **Client → Worker:** a Worker-issued OAuth access token.
* **Worker → Xano:** that user's own Xano user-table JWT.

The client never sees the Xano JWT, and each user's traffic carries their own, so
Xano authenticates and scopes per user natively via `$auth`.

<Accordion title="Why a transparent proxy instead of Cloudflare's McpAgent">
  `McpAgent` runs on Durable Objects, which require a paid Workers plan. A stateless
  reverse proxy keeps the MCP session on Xano — the Worker only round-trips the
  `Mcp-Session-Id` header — so this runs on Cloudflare's free plan.
</Accordion>

## What you need

A **Xano MCP server** with at least one tool, a **Cloudflare account** (free plan is
enough), and **Node.js 18+** — `wrangler` runs through `npx`.

The prompts on this page are written for a coding agent working against your Xano
workspace files, so you also want the [Developer
MCP](/developer-mcp/get-started) and the [Xano CLI](/xano-cli/get-started) set up in
that agent, with `xano workspace pull` already run — that local path is what the first
prompt asks you to paste.

Plus the two URLs below. These are the only values an agent can't discover for you, so
collect them before you start.

<ParamField path="XANO_MCP_STREAM_URL" type="string" required>
  **Where:** <span class="ui-bubble"><Icon icon="link" />Connect this backend</span> at the
  top left of the page → <span class="ui-bubble">MCP Server URLs</span> → the <Icon icon="copy" />
  icon next to your server. Expand the row and take the **streaming** URL, not the SSE one.

  Note that `mcp` appears **twice**. Copy it rather than assembling it by hand — a wrong
  shape 404s instead of erroring usefully.

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

    ```text Example theme={null}
    https://x8ki-letl-twmt.n7.xano.io/x2/mcp/67Dx5RNL/mcp/stream
    ```
  </CodeGroup>
</ParamField>

<ParamField path="XANO_AUTH_BASE" type="string" required>
  **Where:** <span class="ui-bubble">API</span> in the sidebar → open the group holding
  `POST /auth/login` → copy the full URL of any endpoint in it and drop everything after
  the `/api:{canonical}` segment.

  That endpoint must return `{ authToken }` from `security.create_auth_token`. No
  trailing slash.

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

    ```text Example theme={null}
    https://x8ki-letl-twmt.n7.xano.io/api:aBcD1234
    ```
  </CodeGroup>
</ParamField>

<Accordion title="Reading the parts, and the CLI alternative">
  Both URLs share the same `{instance-host}` — the hostname of your Xano instance, like
  `x8ki-letl-twmt.n7.xano.io`. Only the path differs. If you ever need it on its own,
  open **Instance Settings** from the instance selection screen; the host is everything
  between `https://` and the first `/` in the URLs shown there.

  In the stream URL, the `{canonical}` segment is your MCP server's ID, and the
  `/mcp/stream` that follows it is literal — which is why `mcp` appears twice.

  Both canonicals also live in your pulled workspace files, if you'd rather not click
  through the dashboard:

  ```bash theme={null}
  xano workspace pull

  grep canonical ai/mcp_server/*.xs   # → the {canonical} in XANO_MCP_STREAM_URL
  grep canonical api/*/api_group.xs   # → the {canonical} in XANO_AUTH_BASE
  ```

  The instance host isn't in the pulled files — read it off your CLI profile:

  ```bash theme={null}
  xano profile list -d   # instance_origin
  ```

  Assembling the stream URL this way is only safe if you keep the shape exactly as shown
  above. If you're unsure, copy it from **Connect this backend** instead.
</Accordion>

## The work, and who does it

The build splits cleanly in two, and it's worth knowing which is which before you let an
agent loose — they touch completely different things.

| Where             | What changes                                                   | Who                      |
| ----------------- | -------------------------------------------------------------- | ------------------------ |
| **In Xano**       | Your pulled tool files, plus a dashboard toggle per tool       | Agent, except the toggle |
| **On Cloudflare** | Four new local files, deployed to your account with `wrangler` | Agent, start to finish   |

Work through the two sections in order — the Cloudflare side assumes Xano is already
gating tool calls.

## In Xano — gate your tools

Xano is what actually decides whether a tool call is allowed — the Worker only forwards a
JWT to it. So this side comes first: get your tools onto native user authentication. Get it
wrong and the Worker will happily proxy calls to tools that never check anything.

How much there is to do depends on where you're starting. If your tools have no
authentication yet, this is a dashboard toggle and nothing else. If you built your own
checks — a validation function in each tool's stack, middleware on your tools, or an MCP
server trigger reading a token off the connection — native authentication replaces them,
and leaving them in place can lock out the very users you're about to onboard.

This is also the first of the prompt cards. Each one says what its prompt does; **Copy
prompt** puts the full instruction on your clipboard — 20 to 40 lines, with the exact
commands and what counts as passing.

<HandNote>start here</HandNote>

<Prompt description="Audits how your tools authenticate today, proposes what native user authentication replaces, and verifies enforcement." icon="clipboard-check" actions={["copy", "cursor"]}>
  I am putting a Cloudflare Worker OAuth proxy in front of a Xano MCP server, and I want the tools to use native Xano user authentication. Before anything changes, audit what authentication my tools use today.

  Context:

  * Xano MCP stream URL: `[PASTE https://{instance-host}/x2/mcp/{canonical}/mcp/stream]`
  * My Xano workspace files live in: \[PASTE PATH]

  Start read-only. Do not edit, delete, or push anything in this step. I may have no custom auth at all, so do not assume any of the following exists — report what you actually find.

  1. Inventory each tool and how, if at all, it checks the caller today. Look specifically for:
     * `function.run` calls to a custom token- or auth-validation function in the tool's stack
     * a token read by hand out of an input, a query string, or a header
     * `precondition` steps that gate on a token or a secret
     * middleware attached to the tools (Xano middleware can apply to AI tools, so auth may live there rather than in the stack)
     * an MCP server trigger on the connection — check for a `mcp_server_trigger` reading `toolset.token` and throwing `accessdenied`
     * anything reading `$env` for a shared MCP secret

  2. Report a table: tool name, what gates it now, and whether native user authentication replaces that check. Say plainly if the answer is "nothing found, these tools are currently ungated."

  3. Only then propose changes, as a diff for me to approve — do not apply them yet. Native user authentication validates the `Authorization: Bearer <user JWT>` header and populates `$auth`, so anything whose only job was checking a shared token becomes dead weight. Two things to be careful about, and call them out rather than deciding for me:
     * A trigger or middleware may do more than authenticate — filtering which tools a client sees, logging, rate limiting. Keep that, and change only the auth part.
     * If a tool's logic reads the old token variable for anything downstream, replacing it with `$auth` is a behavior change. Flag it; don't silently rewrite it.

  4. After I approve and apply, verify enforcement from the command line. Pick one read-only tool and call it against the stream URL with NO Authorization header, using `Accept: application/json, text/event-stream`. Report whether the response is the unauthorized error or real data.

  Expected pass condition: `{"code":"ERROR_CODE_UNAUTHORIZED","message":"Unauthorized - Authentication Required"}`. If real rows come back instead, that tool's Authentication is still set to Disabled in the Xano dashboard under Connected Tools — tell me which tool, because I have to fix that in the UI, not in code.

  Important: a successful `initialize` proves nothing here. Only a tool call is a real test.
</Prompt>

**What it does:** takes stock before it touches anything. It reads each tool's stack plus any
middleware and MCP server trigger, reports what authenticates your calls today — including
"nothing," if you're starting fresh — and only then proposes what native user authentication
makes redundant, as a diff for you to approve. Then it calls a read-only tool with no
`Authorization` header to confirm Xano rejects it.

<Warning>
  **One part of this is a click, not a prompt.** Setting each tool's **Authentication** to
  **user authentication** lives in the Xano dashboard under **Connected Tools**. It has no
  XanoScript representation, so an agent can't set it and `xano workspace push` won't
  overwrite it — you have to do it yourself.

  The prompt tells you *which* tool is still ungated, which is the part that's tedious by
  hand. Any tool left **Disabled** is callable by anyone who reaches your Xano MCP URL
  directly; the Worker isn't in that path. Gate every one.
</Warning>

## On Cloudflare — build and deploy the Worker

Four small files, deployed to your own Cloudflare account.
`@cloudflare/workers-oauth-provider` does the OAuth heavy lifting: metadata documents,
`/token`, `/register`, PKCE enforcement, token issuance and rotation, and the 401 challenge.

Dynamic client registration — the `/register` endpoint — is deprecated as of the
`2026-07-28` spec revision in favor of Client ID Metadata Documents, but it's retained for
backward compatibility through at least a twelve-month window and Claude still supports it
out of the box. It's the right thing to have, and to check for, today.

<Prompt description="Scaffolds the four-file Worker and drives `wrangler` end to end — KV namespace, deploy, secrets." icon="cloud-arrow-up" actions={["copy", "cursor"]}>
  Build and deploy a stateless Cloudflare Worker that puts OAuth 2.1 in front of a Xano MCP server. Use `@cloudflare/workers-oauth-provider` — it handles metadata documents, /token, /register, PKCE enforcement, token issuance and rotation, and the 401 challenge. Do not use McpAgent or Durable Objects; this has to run on the Cloudflare free plan.

  Values to use:

  * XANO\_MCP\_STREAM\_URL: `[PASTE https://{instance-host}/x2/mcp/{canonical}/mcp/stream]` — note "mcp" appears twice
  * XANO\_AUTH\_BASE: `[PASTE https://{instance-host}/api:{auth-group-canonical}]` — the group hosting POST /auth/login, which returns `{ authToken }`
  * Worker name: xano-mcp-oauth
  * Display name shown to clients: \[PASTE, e.g. My Xano MCP]

  Create four files:

  1. `src/index.ts` — export `new OAuthProvider<Env>({ ... })` with apiRoute "/mcp", apiHandler = the proxy, defaultHandler = the authorize handler, authorizeEndpoint "/authorize", tokenEndpoint "/token", clientRegistrationEndpoint "/register", scopesSupported \["mcp"], `resourceMetadata { resource_name, scopes_supported: ["mcp"] }`, resourceMatchOriginOnly true, accessTokenTTL 3600.

  2. `src/authorize.ts` — GET /authorize runs parseAuthRequest + lookupClient and renders a login form, carrying the auth request in an HMAC-signed state using the COOKIE\_SECRET binding with a 10-minute expiry. POST /login forwards `{ email, password }` to `${XANO_AUTH_BASE}/auth/login` over HTTPS, then calls completeAuthorization with userId = email, `metadata { label: email }`, scope \["mcp"], and `props { userEmail, xanoJWT: authToken }`. Also serve HTML at GET / containing a `<link rel="icon" href="/icon.png">` tag so Claude surfaces can resolve a connector icon.

  3. `src/proxy.ts` — read props.xanoJWT from the validated grant and forward to XANO\_MCP\_STREAM\_URL. Forward the content-type, accept, mcp-session-id, mcp-protocol-version, and last-event-id headers. DROP the client's Authorization header and SET `Authorization: Bearer <xanoJWT>`. Return `upstream.body` directly — never `await upstream.text()`, or streaming breaks. Round-trip Mcp-Session-Id in both directions.

  4. `wrangler.jsonc` — name, main "src/index.ts", compatibility\_date "2025-07-01", a kv\_namespaces entry binding OAUTH\_KV, and observability enabled.

  Security rules, non-negotiable: the login page is HTTPS-only and accepts credentials by POST only, never in a query string. Never log the request body and never persist the password — forward it and drop it. Everything instance-specific goes in secrets, never in committed code.

  Then run the deploy yourself and show me the output of each command:

  * `npm install`
  * `npx wrangler login` (stop and tell me if this needs my browser)
  * `npx wrangler kv namespace create OAUTH_KV`, then write the returned id into wrangler.jsonc
  * `npx wrangler deploy` — report the workers.dev host it prints
  * pipe the two URLs above into `npx wrangler secret put XANO_MCP_STREAM_URL` and `XANO_AUTH_BASE` with `printf '%s'`, and generate COOKIE\_SECRET as 32 random bytes hex and pipe it into `npx wrangler secret put COOKIE_SECRET`
  * `npx wrangler secret list`, then `npx wrangler deploy` again

  Do not echo the secret values back to me in your summary.
</Prompt>

**What it does:** writes `src/index.ts`, `src/authorize.ts`, `src/proxy.ts` and
`wrangler.jsonc`, then runs the whole `wrangler` sequence — login, create the KV namespace,
deploy to learn your `workers.dev` host, set the three secrets, redeploy. It will stop and
ask when `wrangler login` needs your browser. Your two URLs go in as secrets, not code, so
the same Worker can front a different Xano MCP later.

<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>

<Prompt description="Runs the discovery and 401 checks, then reads the `wrangler tail` output for you." icon="flask" actions={["copy", "cursor"]}>
  Verify a deployed Cloudflare Worker is a spec-compliant OAuth 2.1 front end for my Xano MCP server.

  Worker host: `[PASTE https://xano-mcp-oauth.<subdomain>.workers.dev]` — call it \$H below.

  Run these and show me the raw output of each:

  * `curl -i "$H/mcp"` — must be 401, not 200. Claude does not honor a WWW-Authenticate header on a 200. The header must carry `resource_metadata=…` pointing at the protected resource metadata document; without it an OAuth client just fails instead of prompting for login.
  * `curl "$H/.well-known/oauth-authorization-server"` — confirm `code_challenge_methods_supported` includes S256 and that `/register` is present.
  * `curl "$H/.well-known/oauth-protected-resource/mcp"` — confirm `bearer_methods_supported` is `["header"]`. Tokens in the URI query string are prohibited by the spec.

  If curl gets a 403 on /register but a browser works, that is Cloudflare edge bot filtering on the user-agent, not a Worker bug — retry with a normal User-Agent.

  Then start `npx wrangler tail --format pretty` in the background and tell me when it is live. I will run `npx @modelcontextprotocol/inspector` myself and connect with Transport Type "Streamable HTTP", URL \$H/mcp, Authentication "OAuth", and log in with a Xano user-table account.

  While I do that, watch the tail and tell me whether it matches this healthy sequence:

  GET /mcp -> 401
  POST /register -> dynamic client registration
  GET /authorize -> login page
  POST /login -> \[login] xano /auth/login -> 200, then \[login] authorized [you@example.com](mailto:you@example.com)
  POST /token -> token issued
  POST /mcp (initialize) -> \[proxy] user=[you@example.com](mailto:you@example.com) -> xano 200 session=SESSION\_ID
  POST /mcp (tools/call) -> \[proxy] user=[you@example.com](mailto:you@example.com) -> xano 200

  Call out the first line that deviates and what it implies. The pass condition is a tool call returning real rows — `initialize` succeeding is not a passing test.
</Prompt>

**What it does (optional, but five minutes well spent):** `curl`s the three discovery
endpoints to confirm the Worker is spec-compliant, then tails the Worker logs while you
connect with the [MCP Inspector](https://github.com/modelcontextprotocol/inspector)
(`npx @modelcontextprotocol/inspector` — you run this part) and tells you the first line
that deviates from a healthy run. Skipping it just means a later failure is harder to
attribute: this is what separates "the Worker is broken" from "the client is
misconfigured." Every check is written out command by command in [Step 4 — Test with the
MCP Inspector](/ai-tools/mcp-builder/oauth-proxy/manual-setup#step-4-%E2%80%94-test-with-the-mcp-inspector-optional),
if you'd rather run them yourself.

## Connect a client

Add a custom connector pointing at your Worker:

```text theme={null}
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.

**How you know it worked:** a tool call comes back with real rows. That's the only pass
condition that means anything — `initialize` succeeding proves nothing, because Xano's
authentication toggle gates tool *calls* only and an unauthenticated `initialize` still
returns `200`.

**If it doesn't:** connect with the MCP Inspector before you start changing Worker code.
It runs the same handshake outside your client, so it tells you which of the two is
actually at fault — about five minutes, and optional. [Step 4 — Test with the MCP
Inspector](/ai-tools/mcp-builder/oauth-proxy/manual-setup#step-4-%E2%80%94-test-with-the-mcp-inspector-optional)
has the commands and the healthy log sequence to compare against.

## Troubleshooting

<Prompt description="Takes your failing symptom and works the table against your live Worker." icon="stethoscope" actions={["copy", "cursor"]}>
  My Cloudflare Worker OAuth proxy in front of a Xano MCP server is failing. Diagnose it.

  Worker host: `[PASTE https://xano-mcp-oauth.<subdomain>.workers.dev]` — call it \$H below.
  Symptom: \[PASTE the literal error text, or describe what the client does]

  Gather evidence first, before proposing a fix:

  * `npx wrangler tail --format pretty` while I reproduce, looking for `[proxy] … -> xano <status>` and `[login] xano /auth/login -> <status>` lines
  * `npx wrangler secret list` to confirm XANO\_MCP\_STREAM\_URL, XANO\_AUTH\_BASE, and COOKIE\_SECRET are all set
  * `curl -i "$H/mcp"` and `curl "$H/.well-known/oauth-protected-resource/mcp"`

  Then match against these known causes:

  * ERROR\_CODE\_UNAUTHORIZED after a working connection -> the Xano JWT expired (24h default); re-login. If it fails immediately instead, the JWT is not reaching Xano — check the proxy log line.
  * A tool runs with no auth at all -> that tool's Authentication is still Disabled in Xano's Connected Tools. Dashboard fix, not a code fix.
  * `invalid content type for SSE endpoint` -> missing `Accept: application/json, text/event-stream`, or 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.
  * Hangs after initialize -> Mcp-Session-Id is not round-tripping. The proxy must return it on the response and forward it on later requests.
  * Streamed output arrives all at once or stalls -> the 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 is unreachable.
  * curl to /register 403s but a browser works -> Cloudflare edge bot filtering on user-agent. Not a bug.
  * `[login] xano /auth/login -> 4xx` -> bad credentials or wrong XANO\_AUTH\_BASE. Confirm that endpoint returns `{ authToken }`.

  Tell me which one it is and what evidence rules out the others. Show me a diff before changing any Worker code.
</Prompt>

**What it does:** gathers evidence first — the Worker logs, which secrets are set, the two
`curl` checks — then matches your symptom against the nine known failure modes and says
what rules the others out. The same causes are written out as a scannable table in [Manual
setup](/ai-tools/mcp-builder/oauth-proxy/manual-setup#troubleshooting) if you'd rather
search for your error text yourself.

## Stop the daily re-login (optional)

<Prompt description="Kills the 24-hour re-login: adds a Xano refresh endpoint and wires it into `tokenExchangeCallback`." icon="arrows-rotate" actions={["copy", "cursor"]}>
  My Cloudflare Worker OAuth proxy forwards a Xano user JWT upstream to a Xano MCP server. The Xano JWT expires after 24 hours while the OAuth grant lasts longer, so upstream calls start returning 401 and the user has to log in again. Fix that.

  Two parts:

  1. Xano side. Write the XanoScript for a `POST /auth/refresh` endpoint with `auth="user"` in my auth API group. It should read the authenticated user from `$auth` and return a fresh `{ authToken }` via `security.create_auth_token`. My Xano files are at: \[PASTE PATH]. Do not modify the existing /auth/login endpoint.

  2. Worker side. Add a `tokenExchangeCallback` to the `OAuthProvider` config in `src/index.ts`. On refresh-token exchange it should call `${XANO_AUTH_BASE}/auth/refresh` with the current `props.xanoJWT` as the Bearer token and write the returned token into `newProps` so `src/proxy.ts` picks up the fresh JWT on the next request. Keep `props.userEmail` intact.

  Handle the failure path explicitly: if the refresh call returns a 4xx, do not silently pass a stale token through — surface it so the client re-runs the login flow. Log the status only, never the token itself.

  Show me the diff for both sides before applying, then deploy with `npx wrangler deploy` and confirm with `npx wrangler tail --format pretty` that a refresh produces a 200 from Xano.
</Prompt>

**What it does:** the Xano user JWT expires after 24 hours while the OAuth grant lasts
longer, so users get sent back to the login screen once a day. This is the one prompt that
changes **both** sides — it adds a `POST /auth/refresh` endpoint to your Xano auth group
*and* wires the Worker's `tokenExchangeCallback` to call it. Worth running once you're past
testing.

## Add a consent screen (optional)

As built, the Worker treats a successful login as consent — you sign in and the grant is
issued. That's fine while you're the only user. Once anyone else can reach the Worker, they
should see what they're approving and be able to say no.

<Prompt description="Inserts an explicit approve/deny step between login and grant issuance, with CSRF protection, and brands the pages." icon="shield-check" actions={["copy", "cursor"]}>
  My Cloudflare Worker puts OAuth 2.1 in front of a Xano MCP server using `@cloudflare/workers-oauth-provider`. Right now `src/authorize.ts` calls `completeAuthorization` as soon as the Xano login succeeds, so a successful login silently counts as consent. Add a real consent step.

  Display name to show: \[PASTE, e.g. My Xano MCP]

  Do this in `src/authorize.ts`:

  1. Split the flow into two POSTs. `POST /login` keeps forwarding `{ email, password }` to `${XANO_AUTH_BASE}/auth/login`, but on success it must NOT call completeAuthorization. Instead it renders a consent page and carries the auth request plus the returned Xano JWT forward in the existing HMAC-signed state, still signed with COOKIE\_SECRET and still on the 10-minute expiry.

  2. The consent page shows, at minimum: the client's name from `lookupClient` (fall back to the raw `client_id` if the client did not supply one), the redirect URI it will send the user back to, the scopes being requested, and the signed-in email. Label it with the display name above. Render an **Approve** and a **Deny** button as two submits on one form.

  3. `POST /consent` handles both. On approve, call `completeAuthorization` exactly as the code does today — userId = email, `metadata { label: email }`, scope, and `props { userEmail, xanoJWT }` — and redirect to the returned `redirectTo`. On deny, do NOT call completeAuthorization; redirect to the client's registered redirect URI with `error=access_denied` and the client's original `state` echoed back, per RFC 6749.

  4. CSRF protection is the point of this change, so do it properly: mint a random token, bind it to the signed state, put it in a hidden field, and reject any `/consent` POST whose token does not match the state. Also reject requests whose `Origin` header is not the Worker's own origin. Never accept a `redirect_uri` from the form body — always re-read it from the signed state and validate it against the client's registered URIs.

  5. Never re-render the password into any hidden field on the consent page. By this point it has been exchanged for a JWT and must be gone.

  While you're in there, style the login and consent pages to match: same inline CSS, the display name in the heading, and a `<link rel="icon" href="/icon.png">` so Claude surfaces resolve the connector icon.

  Show me the diff before applying. Then `npx wrangler deploy`, and walk me through verifying both paths with `npx @modelcontextprotocol/inspector`: approve issues a token and a tool call returns rows; deny lands back at the client with `access_denied` and no grant.
</Prompt>

**What it does:** splits `/authorize` into login → consent → grant. Deny becomes a real
outcome — the client gets an `access_denied` redirect rather than a grant it never asked
the user about — and the approve path gains CSRF protection, so a third-party page can't
forge an approval on a signed-in user's behalf. It also brands both pages with your display
name and icon, which is what people actually see during the handshake.

Skip it and the Worker still works exactly as before; you just have no consent record and
no deny path, which is only safe while you're the only user.

<Accordion title="Other limitations worth knowing about">
  * **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`.
  * **Connector icons** are resolved from the HTML your Worker serves at `/`, and are cached
    per domain. See [Icons and display
    name](/ai-tools/mcp-builder/oauth-proxy/manual-setup#icons-and-display-name).
</Accordion>

## Next steps

<CardGroup cols={2}>
  <Card title="Manual setup" icon="wrench" href="/ai-tools/mcp-builder/oauth-proxy/manual-setup">
    Every step by hand, the four Worker files in full, and the troubleshooting table.
  </Card>

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

  <Card title="MCP Servers" icon="book" href="/ai-tools/mcp-builder">
    Building servers, connection URL anatomy, and URL parameters.
  </Card>
</CardGroup>


## Related topics

- [MCP Builder](/ai-tools/mcp-builder.md)
- [Build the OAuth Worker by hand](/ai-tools/mcp-builder/oauth-proxy/manual-setup.md)
- [Connecting Clients](/ai-tools/mcp-builder/connecting-clients.md)
