# Per-user OAuth to upstream MCP servers

The `mcp-token-exchange-inbound` policy resolves a gateway-managed upstream
credential and applies it to the request before the proxy forwards it. It's the
**upstream** side of the [two-layer authentication model](./overview.mdx) —
every request that reaches an OAuth-protected upstream MCP server goes through
it.

This page covers what the policy does, the two auth modes it supports, how
client registration works, the user-facing browser consent flow, and the moving
parts around token refresh and reconsent. The full options schema lives on the
policy reference page.

Configure the policy in `config/policies.json` and attach it to each MCP route
in `config/routes.oas.json`. See the
[code-config overview](../code-config/overview.mdx) for the full project setup.

## What it does

On every MCP request to a route that uses the policy:

1. Identify the authenticated user from the gateway-issued bearer.
2. Look up the **upstream connection** for that user and upstream.
3. If a usable upstream access token exists, inject it as
   `Authorization: Bearer <upstream-token>` and let the proxy forward.
4. If the upstream connection is missing or revoked, return a JSON-RPC
   **connect-required** error pointing at the URL the user must open to complete
   upstream OAuth.
5. If the upstream returns a `401` mid-request, refresh the upstream credential
   and retry the upstream fetch once before propagating the error.

Inbound auth headers don't leak to the upstream.

The downstream OAuth policy and this policy are paired on the same route:

```jsonc
{
  "policies": {
    "inbound": ["auth0-managed-oauth", "mcp-token-exchange-linear"],
  },
}
```

Only one MCP token-exchange policy is allowed per route. The route's upstream
URL comes from `McpProxyHandler`'s `rewritePattern` option, not from the policy.

:::caution{title="Compatibility date 2026-03-01"}

MCP Gateway features require `compatibilityDate >= 2026-03-01` in `zuplo.jsonc`.
See [compatibility dates](../code-config/compatibility-dates.mdx).

:::

## When to use this policy

Use `mcp-token-exchange-inbound` when the upstream MCP server requires OAuth —
either per user or as a shared service account. **Both modes are OAuth.** The
policy doesn't handle static API keys or arbitrary header injection.

For non-OAuth upstreams, omit this policy and compose ordinary Zuplo policies
alongside `McpProxyHandler`:

- **API key in a custom header:** use
  [`set-upstream-api-key-inbound`](../../policies/set-upstream-api-key-inbound.mdx).
- **Static request headers:** use
  [`SetHeadersInboundPolicy`](../../policies/set-headers-inbound.mdx).
- **Anonymous upstream:** no policy is needed — `McpProxyHandler` proxies
  through directly.

The corp dogfood gateway uses `SetUpstreamApiKeyInboundPolicy` for Firecrawl
alongside other upstreams that use OAuth, all in the same project.

## Auth modes

`authMode` is the central knob — it decides who owns the upstream credential.

| `authMode`       | Owner                                                  | Use case                                                                                                                                               |
| ---------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `"user-oauth"`   | Each user has their own per-upstream OAuth connection. | The default. Linear, Notion, Stripe, GitHub, most SaaS MCP servers.                                                                                    |
| `"shared-oauth"` | One gateway-wide OAuth grant used by all users.        | A single service account or admin-owned connection. An administrator completes a one-time setup; subsequent user requests reuse the shared credential. |

### user-oauth

Per-user is the standard mode and what most upstreams use. The first time each
user hits a route, the policy returns a connect-required error; the user opens
the URL in a browser; they complete the upstream provider's OAuth flow; the
gateway stores the resulting tokens encrypted, keyed by the user's subject ID.
Subsequent requests from that user are transparent.

### shared-oauth

Shared mode uses a single gateway-wide OAuth grant. There's no per-user connect
flow — instead, an administrator completes a one-time connection, and every
authenticated user reuses that credential when calling the upstream. The gateway
returns an `admin_connect_required` connect-required error if no shared
connection exists.

Shared mode is appropriate when:

- The upstream uses a service account that represents the organization, not
  individual users.
- Auditing happens at the gateway level (per user) rather than at the upstream
  (where every call looks like the same service account).

## Client registration

The `clientRegistration` option determines how the gateway identifies itself to
the upstream OAuth provider.

| Mode                                                                                                               | What happens                                                                                                                                                                                                                                                                                   |
| ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `{ "mode": "auto" }` (default)                                                                                     | The gateway publishes a per-upstream **OIDC Client ID Metadata Document** at `/.well-known/oauth-client/{connection}?authProfileId=...` and tells the upstream that URL is the client ID. If the upstream doesn't accept CIMD, the gateway falls back to RFC 7591 Dynamic Client Registration. |
| `{ "mode": "manual", "clientId": "...", "clientSecret": "...", "tokenEndpointAuthMethod": "client_secret_basic" }` | Pre-registered OAuth app. The gateway uses your `clientId` directly and authenticates to the upstream token endpoint with the configured method.                                                                                                                                               |

Both modes are first-class. Use `auto` for upstreams that support CIMD or DCR
out of the box — it requires nothing from the upstream provider beyond standard
MCP authorization spec support and has no client secrets to rotate. Use `manual`
when you want to pin a pre-registered OAuth app: your organization manages OAuth
client lifecycle centrally, the upstream requires an approved client, or you
need to share one OAuth client across multiple routes.

Auto-mode CIMD documents are accessible to the upstream provider over HTTPS —
the upstream fetches them as part of its OAuth registration flow. The CIMD URL
includes the `authProfileId` query parameter so the gateway can scope client
identity per `(upstream, authMode)` pair.

## Scope selection

`scopes` is an optional array. When set, the gateway uses exactly those values
on every upstream authorization request, joined by `scopeDelimiter` (default
single space).

When `scopes` is omitted or empty, the gateway falls back through the following
sources in order:

1. The `scope=` value from the upstream's most recent `WWW-Authenticate`
   challenge.
2. The `scopes_supported` array in the upstream's Protected Resource Metadata.
3. No `scope` parameter at all.

Explicit `scopes` always win. Set them whenever the upstream provider requires
specific values that aren't discoverable from MCP metadata — Microsoft 365,
Slack, PostHog, and several other providers fall into this bucket. The corp
dogfood configures `["grafana:read", "grafana:write"]` for Grafana Cloud and
`["mcp"]` for Stripe, for example.

## Per-user OAuth flow

The browser flow is what users actually see. It runs the first time a user hits
an OAuth-protected upstream they haven't connected, and again whenever the
upstream revokes the gateway's client.

<Diagram height="h-80">
  <DiagramNode id="user">User browser</DiagramNode>
  <DiagramNode id="client">MCP Client</DiagramNode>
  <DiagramGroup id="gateway" label="Zuplo Gateway">
    <DiagramNode id="connect" variant="zuplo">
      Upstream connect
    </DiagramNode>
    <DiagramNode id="route" variant="zuplo">
      /mcp/linear-v1
    </DiagramNode>
  </DiagramGroup>
  <DiagramNode id="oauth">Linear OAuth</DiagramNode>
  <DiagramNode id="upstream">Linear MCP</DiagramNode>
  <DiagramEdge from="client" to="route" label="1. First call" />
  <DiagramEdge from="client" to="user" label="2. Open authUrl" />
  <DiagramEdge from="user" to="oauth" label="3. Login + consent" />
  <DiagramEdge from="oauth" to="connect" label="4. Callback" />
  <DiagramEdge from="route" to="upstream" label="5. Retry, proxied" />
</Diagram>

Modern MCP clients implement the URL-elicitation extension and open the URL
automatically. Older clients surface the URL as part of the JSON-RPC error
message — the user copies it into a browser.

## Connect-required states

The connect-required error carries a `state` field that distinguishes the three
reasons the user might need to act.

| State                    | Meaning                                                                                                                  | Typical UI message                                                             |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
| `authenticating`         | First-time connection. User hasn't authorized the upstream yet.                                                          | "Connect to `{provider}` to continue."                                         |
| `reconsent_required`     | Existing connection but the upstream revoked the client or invalidated the refresh token. The user needs to reauthorize. | "`{provider}` authorization must be renewed."                                  |
| `admin_connect_required` | `authMode: shared-oauth` and no shared connection exists yet. Only an administrator can complete the flow.               | "An administrator must connect `{provider}` before this service is available." |

The full JSON-RPC error payload looks like:

```jsonc
{
  "jsonrpc": "2.0",
  "id": "1",
  "error": {
    "code": -32042,
    "message": "Connect Linear to continue.",
    "data": {
      "state": "authenticating",
      "upstreamServerId": "linear",
      "operationId": "linear-mcp-server",
      "authUrl": "https://gateway.example.com/auth/connections/linear/connect?browserTicket=eyJ...&operationId=linear-mcp-server",
      "nextAction": "redirect",
      "authProfileId": "linear:user-oauth",
    },
  },
}
```

The `-32042` error code is MCP's `URLElicitationRequiredError`. Clients that
support URL elicitation open `authUrl` directly; others render the message and
let the user open the URL manually.

## Multi-upstream consent

Each MCP route proxies to exactly one upstream MCP server, so the consent page
typically shows one upstream to connect. The page renders the per-upstream
**Connect** button alongside the **Authorize** action; the **Authorize** action
is enabled once every required upstream connection is complete.

The consent page is part of the gateway and renders automatically whenever a
user lands at `/oauth/setup` mid-flow.

## Token refresh

The gateway transparently refreshes the upstream access token from the stored
refresh token. When the upstream returns a 401 mid-request — for example,
because the upstream's session-bound token expired — the gateway refreshes the
upstream credential and retries the upstream fetch once. If the refresh fails or
produces another connect-required, the gateway returns the JSON-RPC
connect-required to the client.

## Per-upstream metadata URL

By default, the gateway derives the upstream Protected Resource Metadata URL
from the route's `rewritePattern`:

```text
rewritePattern:                https://mcp.linear.app/mcp
default PRM URL:               https://mcp.linear.app/.well-known/oauth-protected-resource/mcp
```

When the upstream serves PRM at a non-default path, override it explicitly with
`protectedResourceMetadataUrl`. Linear, for example, serves PRM at the origin's
root, not under `/mcp`:

```json
{
  "options": {
    "displayName": "Linear",
    "protectedResourceMetadataUrl": "https://mcp.linear.app/.well-known/oauth-protected-resource",
    "authMode": "user-oauth",
    "clientRegistration": { "mode": "auto" }
  }
}
```

When in doubt, look at what the upstream's MCP endpoint returns in its
`WWW-Authenticate` header on an unauthenticated request — the
`resource_metadata=` parameter on that header is the canonical URL.

## Worked examples

These are pared-down versions of three policies from the corp dogfood gateway.
Each pairs with an `McpProxyHandler` route whose `rewritePattern` is the
upstream MCP URL.

### Linear (auto registration, PRM override)

```json
{
  "name": "mcp-token-exchange-linear",
  "policyType": "mcp-token-exchange-inbound",
  "handler": {
    "module": "$import(@zuplo/runtime/mcp-gateway)",
    "export": "McpTokenExchangeInboundPolicy",
    "options": {
      "displayName": "Linear",
      "summary": "Linear MCP upstream used to dogfood user-owned OAuth.",
      "protectedResourceMetadataUrl": "https://mcp.linear.app/.well-known/oauth-protected-resource",
      "authMode": "user-oauth",
      "scopes": [],
      "clientRegistration": { "mode": "auto" }
    }
  }
}
```

The corresponding route:

```jsonc
"/mcp/linear-v1": {
  "get,post": {
    "operationId": "linear-mcp-server",
    "x-zuplo-route": {
      "corsPolicy": "none",
      "handler": {
        "module": "$import(@zuplo/runtime/mcp-gateway)",
        "export": "McpProxyHandler",
        "options": { "rewritePattern": "https://mcp.linear.app/mcp" }
      },
      "policies": {
        "inbound": ["auth0-managed-oauth", "mcp-token-exchange-linear"]
      }
    }
  }
}
```

### Stripe (explicit scope)

```json
{
  "name": "mcp-token-exchange-stripe",
  "policyType": "mcp-token-exchange-inbound",
  "handler": {
    "module": "$import(@zuplo/runtime/mcp-gateway)",
    "export": "McpTokenExchangeInboundPolicy",
    "options": {
      "displayName": "Stripe",
      "summary": "Stripe MCP upstream used to dogfood user-owned OAuth.",
      "authMode": "user-oauth",
      "scopes": ["mcp"],
      "clientRegistration": { "mode": "auto" }
    }
  }
}
```

Stripe requires the bare `mcp` scope explicitly. The default PRM URL (derived
from the route's `rewritePattern` of `https://mcp.stripe.com/mcp`) is correct,
so no override is needed.

### Notion (PRM override at `/mcp` path)

```json
{
  "name": "mcp-token-exchange-notion",
  "policyType": "mcp-token-exchange-inbound",
  "handler": {
    "module": "$import(@zuplo/runtime/mcp-gateway)",
    "export": "McpTokenExchangeInboundPolicy",
    "options": {
      "displayName": "Notion",
      "protectedResourceMetadataUrl": "https://mcp.notion.com/.well-known/oauth-protected-resource/mcp",
      "authMode": "user-oauth",
      "scopes": [],
      "clientRegistration": { "mode": "auto" }
    }
  }
}
```

## Full options reference

The complete schema lives on the policy reference page. The fields you'll touch
most often:

| Option                         | Required | Default                                      | Notes                                                                                 |
| ------------------------------ | -------- | -------------------------------------------- | ------------------------------------------------------------------------------------- |
| `id`                           | no       | inferred from `mcp-token-exchange-{id}` name | Stable id for the upstream. Changing it strands stored connections.                   |
| `displayName`                  | yes      | —                                            | Display name shown in connect-required errors, the consent page, and analytics.       |
| `summary`                      | no       | —                                            | Human-readable summary on the consent page.                                           |
| `authMode`                     | yes      | —                                            | `"user-oauth"` or `"shared-oauth"`.                                                   |
| `protectedResourceMetadataUrl` | no       | derived from `rewritePattern`                | Override when the upstream serves PRM at a non-default path.                          |
| `scopes`                       | no       | `[]`                                         | OAuth scopes requested from the upstream. Empty means "use discovery fallback".       |
| `scopeDelimiter`               | no       | `" "`                                        | Delimiter joining scopes in the authorization request.                                |
| `clientRegistration`           | no       | `{ "mode": "auto" }`                         | `auto` uses CIMD then falls back to DCR; `manual` uses a pre-registered OAuth client. |
| `clientId`                     | no       | —                                            | OAuth client ID for manual registration.                                              |
| `clientSecret`                 | no       | —                                            | OAuth client secret for manual registration. Use `$env(...)`.                         |
| `tokenEndpointAuthMethod`      | no       | `client_secret_basic` (when manual)          | Manual-mode token endpoint authentication method.                                     |

## Common issues

- **`compatibilityDate < 2026-03-01`.** Upstream 401 retries fail. Bump the
  compatibility date in `zuplo.jsonc`.
- **Connect-required loop.** The user completes the upstream flow but the next
  MCP request returns a fresh connect-required error. Usually means the upstream
  provider isn't returning a refresh token, so the gateway treats every request
  as a fresh connect. Check the upstream provider's app configuration for
  refresh-token grant type support.
- **`upstream_client_registration_required` error.** The upstream blocked both
  CIMD and DCR. Use `clientRegistration: { mode: "manual" }` with a
  pre-registered OAuth app instead.
- **Wrong PRM URL.** The default PRM URL doesn't match the upstream's actual
  metadata endpoint. Set `protectedResourceMetadataUrl` explicitly.
- **Scope mismatch.** The upstream rejects the gateway's authorization request
  with `invalid_scope`. Configure `scopes` explicitly with the values the
  upstream expects.

## Related

- [Authentication overview](./overview.mdx)
- `mcp-token-exchange-inbound` policy reference
- [`McpProxyHandler` reference](../code-config/mcp-proxy-handler.mdx)
- [Multi-upstream pattern](../code-config/multi-upstream.mdx)
- [Compatibility dates](../code-config/compatibility-dates.mdx)
- [Manual OAuth testing](./manual-oauth-testing.mdx)
