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.
This policy is code-config only today. The Portal UI surfaces Origin MCPs
and Virtual MCPs but does not yet expose upstream OAuth configuration. Configure
it in config/policies.json and route attachments in config/routes.oas.json.
See the code-config overview for the full setup.
What it does
On every MCP request to a route that uses the policy:
- Read the gateway-issued bearer (already validated by the downstream OAuth policy in front of this one) to identify the user.
- Look up the stored 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.
- Install a response hook that watches for an upstream
401. On hit, refresh the upstream credential (using any newscope=advertised in the upstreamWWW-Authenticateheader) and retry the upstream fetch once. - Strip the inbound
Authorization,Cookie, andCookie2headers from the request so they never leak 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
This policy requires compatibilityDate >= 2026-03-01 in zuplo.jsonc. The
upstream 401 retry hook depends on chained response-hook semantics that landed
on that date. Older projects must bump the compatibility date before adding this
policy. See compatibility dates for
details.
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. |
Auto is the right default. It requires nothing from the upstream provider beyond standard MCP authorization spec support, and it has no client secrets to rotate. Use manual when the upstream provider blocks both CIMD and DCR, or when your organization requires a pre-vetted OAuth app registration.
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.
Code
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
A Virtual MCP routes 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 multi-upstream UI pattern is in place to keep future flows that bind multiple consent steps (for example, multiple shared service accounts on one project) consistent.
The page is server-rendered HTML hosted on the gateway. There's no client SDK to
add to your project — the consent page is part of the gateway's internal routes
and renders automatically whenever a user lands at /oauth/setup mid-flow.
Token refresh and the 401 retry hook
The gateway delegates upstream token refresh to the
@modelcontextprotocol/sdk
OAuth client provider. Per-request, the policy resolves the stored connection
and hands the SDK's provider to the proxy; the SDK refreshes the access token
from the stored refresh token transparently.
When the upstream returns a 401 mid-request — for example, because the upstream's session-bound token expired between the gateway's last refresh check and the upstream's clock — the policy's response hook:
- Reads any
scope=value from the upstreamWWW-Authenticateheader, in case the upstream is requesting elevated scopes (MCP's incremental-scope-consent path). - Force-refreshes the upstream credential.
- Retries the upstream fetch exactly once.
- If the refresh itself fails or produces another connect-required, the gateway returns the JSON-RPC connect-required to the client.
The retry hook is what requires compatibilityDate >= 2026-03-01. Without that
date, later response hooks can overwrite the retry response.
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. Used as the per-user OAuth storage key. 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 DCR; manual uses a pre-registered 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. The retry hook fails to install correctly. 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. Switch to manual registration with a pre-registered OAuth app.- 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