Per-user OAuth to upstream MCP servers
The gateway sits between an MCP client and the upstream MCP servers a team
relies on. The inbound OAuth surface is the one MCP clients connect to; the
outbound surface is where the gateway authenticates to each upstream on the
user's behalf. This page is about the outbound surface — the one the
mcp-token-exchange-inbound policy controls.
For the policy steps, options reference, and worked examples, see Connect a gateway to an upstream OAuth provider.
Why the gateway acts as an OAuth client
Modern MCP servers — Linear, Notion, Stripe, GitHub, Grafana Cloud, and many
others — are OAuth-protected resources. They expect a Bearer token that
represents a specific user (or service identity) granted by their own OAuth
authorization server.
When an MCP client connects to a Zuplo MCP Gateway route, it presents the gateway's bearer token. That token authenticates the user to the gateway but isn't valid against the upstream. The spec explicitly forbids forwarding the inbound token to an upstream, so the gateway must mint an independent upstream credential and attach it to the upstream request.
The gateway does that by acting as a standard OAuth client to each upstream — discovering the upstream's authorization server, registering itself as a client, redirecting the user through the upstream's authorization flow, capturing the resulting tokens, and storing them encrypted at rest. On subsequent requests, the gateway resolves the stored credential, refreshes it if necessary, and applies it to the upstream request.
The two auth modes
authMode is the central knob. It decides who owns the upstream credential.
user-oauth
Per-user is the default and the right choice for most upstreams. Each user has their own per-upstream OAuth connection. The first time a user hits the route, the gateway returns a connect-required error; the user completes the upstream provider's OAuth flow in a browser; the gateway stores the resulting tokens encrypted, keyed by the user's subject ID. Subsequent requests from that user are transparent.
This mode is what Linear, Notion, Stripe, GitHub, and most SaaS MCP servers use. It preserves per-user attribution end to end — the upstream sees the specific user making the call, and the gateway's analytics record the same subject ID against every event.
shared-oauth
Shared mode uses a single gateway-wide OAuth grant. There's no per-user connect
flow. An administrator completes a one-time connection through the upstream's
OAuth provider, and every authenticated user reuses that credential when calling
the upstream. If no shared connection exists yet, the gateway returns an
admin_connect_required error to let the client know an administrator action is
needed.
Shared mode is appropriate when the upstream uses a service account that represents the organization rather than individual users, or when 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 gateway needs to identify itself to the upstream OAuth provider before it
can request tokens. The clientRegistration option controls how:
- CIMD with DCR fallback (
{ "mode": "auto" }) — the default. The gateway publishes a per-upstream OAuth 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. Auto mode requires nothing from the upstream provider beyond standard MCP authorization spec support and has no client secrets to rotate. - Manual — the gateway uses a pre-registered
clientId(and optionalclientSecret) and authenticates to the upstream token endpoint with a configured method. Manual mode is the right choice when an organization manages OAuth client lifecycle centrally, the upstream provider requires an approved client, or one OAuth client should be shared across multiple routes.
Both modes are first-class. 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.
How the gateway picks scopes
The gateway needs to know which OAuth scopes to request from the upstream. It considers three sources in order:
- An explicit
scopesarray on the policy. When set, the gateway uses exactly those values on every upstream authorization request. - The
scope=value from the upstream's most recentWWW-Authenticatechallenge. Used when no explicit scopes are configured. - The
scopes_supportedarray in the upstream's Protected Resource Metadata. Used as the final fallback before falling through to noscopeparameter at all.
Explicit scopes always win. Microsoft 365, Slack, PostHog, Stripe, and Grafana Cloud are examples of upstreams that need explicit scopes — their PRM either lists too many scopes or none at all, so deferring to discovery alone isn't enough.
What the user sees
The browser flow 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.
Each MCP route proxies to exactly one upstream, so the consent page typically
shows one upstream to connect. The consent page is part of the gateway and
renders automatically whenever a user lands at /oauth/setup mid-flow.
Connect-required states
When the gateway needs the user to act, it returns a JSON-RPC error with a
state field that distinguishes the three reasons.
| 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.
Refresh and 401 retry
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 state, the gateway returns the JSON-RPC connect-required to the client and the user sees the reconsent flow.
Stored refresh tokens stay valid as long as the upstream provider honors them.
When an upstream's policy revokes a refresh token — for example, because the
user revoked the connection from the upstream's dashboard — the next request
surfaces reconsent_required and the user re-authorizes through the same
browser flow.
Where the metadata URL comes from
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 (Linear's PRM lives at the
origin's root, not under /mcp), the policy's protectedResourceMetadataUrl
option overrides the default. The canonical source of truth is the
resource_metadata= parameter on the upstream's WWW-Authenticate challenge to
an unauthenticated request.
Related
- Connect a gateway to an upstream OAuth provider — how to attach the policy, pick modes, and verify the connect flow.
- Authentication overview — the two-layer model and how inbound and outbound OAuth fit together.
- Manual OAuth testing — verify the gateway's
OAuth surface end to end with
curlandopenssl. - Compatibility dates — the
2026-03-01requirement for upstream 401 retries and other MCP Gateway behaviors.