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 —
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 for the full project setup.
What it does
On every MCP request to a route that uses the policy:
- Identify the authenticated user from the gateway-issued bearer.
- Look up the upstream connection for that user and upstream.
- If a usable upstream access token exists, inject it as
Authorization: Bearer <upstream-token>and let the proxy forward. - 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.
- If the upstream returns a
401mid-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:
Code
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.
Compatibility date 2026-03-01
MCP Gateway features require compatibilityDate >= 2026-03-01 in zuplo.jsonc.
See compatibility dates.
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. - Static request headers: use
SetHeadersInboundPolicy. - Anonymous upstream: no policy is needed —
McpProxyHandlerproxies 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:
- The
scope=value from the upstream's most recentWWW-Authenticatechallenge. - The
scopes_supportedarray in the upstream's Protected Resource Metadata. - No
scopeparameter 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.
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:
Code
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:
Code
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:
Code
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)
Code
The corresponding route:
Code
Stripe (explicit scope)
Code
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)
Code
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 inzuplo.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_requirederror. The upstream blocked both CIMD and DCR. UseclientRegistration: { 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
protectedResourceMetadataUrlexplicitly. - Scope mismatch. The upstream rejects the gateway's authorization request
with
invalid_scope. Configurescopesexplicitly with the values the upstream expects.
Related
- Authentication overview
mcp-token-exchange-inboundpolicy referenceMcpProxyHandlerreference- Multi-upstream pattern
- Compatibility dates
- Manual OAuth testing