oauthProxy when your identity provider gives you one fixed clientId and clientSecret and cannot register MCP clients itself. GitHub and Google work this way. With oauthProxy, your MCP server becomes its own OAuth authorization server for MCP clients, and signs users in to the upstream provider as one ordinary OAuth app.
The upstream client secret and the user’s upstream tokens stay on your server. MCP clients only receive tokens that your server issues. Tools call the upstream API as the user with upstreamToken(ctx.auth).
If your provider supports Dynamic Client Registration (DCR), use a built-in provider or a Custom Provider instead. Those keep your server a resource server only.
How the proxy works
The proxy sits between two OAuth relationships. Toward MCP clients, it is the authorization server: it identifies clients, shows a consent page, and issues its own access and refresh tokens. Toward the upstream provider, it is a confidential OAuth client with one registered callback URL.The sign-in flow
- The client calls the MCP endpoint without a token.
- The server answers 401 with the URL of its protected resource metadata.
- The metadata names the server itself as the authorization server and lists its
/mcp/oauth/*endpoints. The client identifies itself with a client ID metadata document URL, or registers at/mcp/oauth/register. - The client opens
/mcp/oauth/authorizein the browser with its PKCE challenge andstate. - The proxy checks the client, the redirect URI, the PKCE challenge, and the RFC 8707
resource. - The proxy shows its consent page.
- The user approves.
- The proxy redirects the browser to the upstream provider with the proxy’s own
client_idand its own PKCE challenge. - The user signs in to the upstream provider and approves the OAuth app.
- The upstream provider redirects the browser to
/mcp/oauth/callbackwith a code. - The proxy checks that the callback belongs to the browser that approved consent, using a signed cookie.
- The proxy exchanges the code on the server with the client secret and its PKCE verifier, then reads the user, for example from Google’s userinfo endpoint.
- The proxy redirects the browser to the client’s redirect URI with a one-time code and the client’s original
state. - The client receives the code.
- The client exchanges the code at
/mcp/oauth/tokenwith its PKCE verifier. - The proxy returns its own access token (
mcp_at_…) and refresh token (mcp_rt_…). - The client calls the MCP endpoint with the access token. The proxy looks up the grant and refreshes the upstream token if it expires soon.
Set up a Google sign-in
1
Configure the Google Auth Platform
In the Google Cloud console, open Google Auth Platform for your project and set up the app’s branding and audience. While the publishing status is Testing, add each Google account that signs in under Audience > Test users.
2
Create an OAuth client
Under Clients, create a client of type Web application. Add the proxy’s callback as an Authorized redirect URI:
http://localhost:3000/mcp/oauth/callback for local development. Google never sees the MCP client’s own redirect URI, so this one URL serves every client. Copy the client ID and the client secret.3
Set the environment
.env
4
Configure the server
src/index.ts
5
Connect a client
Run The client opens the consent page, then Google. After sign-in, call
mcp-use dev and connect a client to http://localhost:3000/mcp:whoami.firestoreStorage and stores each user’s notes under their Google subject.
Register the callback URL
Register exactly one callback URL with the upstream provider:basePath of /mcp, that is https://mcp.example.com/mcp/oauth/callback in production and http://localhost:3000/mcp/oauth/callback locally.
The proxy serves all of its endpoints under <basePath>/oauth/:
The server publishes protected resource metadata that names itself as the authorization server, and RFC 8414 authorization server metadata that lists these endpoints. You do not configure either by hand.
Use the upstream token in tools
upstreamToken(ctx.auth) returns the upstream access token for the signed-in user. It takes only the auth object, so a helper that receives ctx.auth can call it too. It throws when the request is not signed in through oauthProxy, so a tool never calls the upstream API without the user’s token.
This tool lists the user’s next Google Calendar events. Enable the Google Calendar API in the Google Cloud project, and request its scope from Google:
_meta.
ctx.auth on a proxy server contains:
subject is the provider’s stable user ID: the OpenID Connect sub for Google, or the numeric user ID for GitHub. It is never an email address or a login name, which can change. Use subject as the key for any data you store per user.
Map the user
PassmapUser to shape ctx.auth.user, for example to match the id field that the other providers use:
mapUser runs on every request. It controls the user shape, not who can sign in: any user of the upstream provider can complete the sign-in. To restrict users, throw from your tools, or check ctx.auth.user in middleware.
Choose an upstream provider
google() always requests openid and, by default, email and profile. Add Google API scopes to scopes as your tools need them.
google() requests offline access, so Google returns a refresh token and the proxy refreshes Google tokens on the server. If Google returns no refresh token and none is stored for the user, the proxy sends the user through Google consent once more.
While the app’s publishing status is Testing, Google expires refresh tokens after 7 days unless the app requests only the openid, email, and profile scopes. Users then sign in again. Publish the app to remove the limit.
GitHub
In GitHub Developer settings, choose OAuth Apps > New OAuth App, and set the callback URL to<public origin>/mcp/oauth/callback. Turn on Expire user access tokens so that GitHub issues refresh tokens; the proxy then refreshes the GitHub token on the server.
baseUrl to the web host, and apiUrl when the REST API lives elsewhere. The GitHub example calls the GitHub API as the user with Octokit and keeps sign-ins in Redis.
Any OAuth 2.0 provider
Useupstream() for a provider that has authorization and token endpoints. fetchUser reads the signed-in user with the upstream tokens and must return a stable subject.
id appears in storage keys and ctx.auth.payload.provider; name appears on the consent page. Endpoints must use HTTPS (HTTP is accepted on localhost). PKCE is on by default; set pkce: false only for a provider that rejects it. Set issuer when the token endpoint returns OpenID Connect ID tokens, and authorizeParams for extra query parameters such as prompt.
Choose storage
The proxy keeps clients, sign-in state, one-time codes, grants, and upstream tokens between requests, so it needs a store that every instance of the server shares. The proxy encrypts every value with yoursecret before the store sees it, and stores only SHA-256 hashes of tokens in keys.
mcp-use/oauth/storage has adapters for Upstash, Redis, Postgres, Firestore, and Cloudflare D1 or Durable Objects. See OAuth Proxy storage to pick one or write your own.
What each environment requires
oauthProxy throws when the server mounts without a required storage or secret. memoryStorage() is refused outside localhost. Without a secret, the proxy logs a warning and uses a random key, so sign-ins end when the process stops.
Set and rotate the secret
The proxy derives its encryption and cookie keys fromsecret, which defaults to the MCP_USE_OAUTH_SECRET environment variable. Use a random string of at least 32 characters:
Restrict which clients connect
By default, any MCP client can connect. A client identifies itself in one of two ways:- Client ID metadata document (CIMD). The client uses an HTTPS URL as its
client_id, and the proxy fetches its metadata from that URL. The MCP specification prefers this. Claude and ChatGPT use it. - Dynamic client registration (DCR). The client registers at
/mcp/oauth/registerand gets amcp_client_…ID. Registered clients are public clients: the proxy never issues a client secret.
clients to allow only the apps you list. Dynamic client registration turns off while clients is set, because a registered client names itself.
clients controls which apps connect, not which users sign in. dcr: true together with clients throws.
The proxy fetches metadata documents only from hosts that resolve to public addresses. On Node.js and Bun it connects only to the addresses it checked. On Cloudflare Workers and Deno, fetch resolves the host again, so list the trusted metadata documents in clients to close the DNS-rebinding window there. Passing a custom fetch turns the host check off; your fetch must then enforce its own network policy.
Configure the consent page
The proxy shows a consent page before it sends the user upstream. The page names your server by its domain, the app that asks, where the browser returns, the upstream provider, and the scopes. Consent defends against a malicious client that reuses an earlier upstream approval (a confused-deputy attack), so it is on by default.Render a custom consent page
consent.page takes a React component that the proxy renders on the server with react-dom/server, so install react and react-dom. The component receives ConsentPageProps and must render a form that posts to form.action with two decision buttons. form.action already carries the CSRF token, so post to it unchanged and add no hidden fields:
src/consent.tsx
src/index.ts
<style> element; scripts do not run.
Scopes and token lifetimes
The proxy’s own scopes are separate from the upstream scopes:- Upstream scopes (
google({ scopes })) are what the proxy requests from the provider on every sign-in. - Proxy scopes (
scopesSupportedorrequiredScopes) are what the proxy advertises and grants to MCP clients. Requested scopes that the proxy does not know are dropped, not rejected, because clients often send upstream scope names such asread:user. When no known scope is left, the grant gets every supported scope.
accessTokenTtl can be longer than the upstream token’s lifetime. On each MCP request, the proxy refreshes the upstream token when it expires within 60 seconds and the provider issued a refresh token. One instance refreshes under a storage lock while the others wait for its result. When the upstream token has expired and cannot be refreshed, the request fails with 401 and the client signs in again.
Each refresh returns a new refresh token. The previous refresh token works once more within 60 seconds, so a client can retry after a dropped response. Reusing it after that ends the whole sign-in, because someone else holds a copy.
Revoking either token at /mcp/oauth/revoke ends the whole sign-in: the grant, its access tokens, and its refresh tokens.
Deploy to production
1
Set the public URL
Set
MCP_URL to the public origin, with no path, for example https://mcp.example.com.2
Register the production callback
Add
https://mcp.example.com/mcp/oauth/callback to the upstream OAuth app.3
Configure a shared store and a secret
Pass a store from
mcp-use/oauth/storage that every instance can reach, and set MCP_USE_OAUTH_SECRET on every instance. All instances must share the same secret.4
Decide which clients connect
Leave
clients unset to accept any client, or list the apps you support.Migrate from v1 oauthProxy
v2 keeps the idea of v1 oauthProxy: one OAuth app at a provider without
Dynamic Client Registration (DCR) signs in every MCP client. The token model is
new. v1 passed the upstream provider’s tokens through to the client, and
verified them on each request with your verifyToken. v2 issues its own
tokens to the client and keeps the upstream tokens encrypted on the server.
In v1, the client holds the upstream token:
In v2, the client holds the proxy’s token, and the upstream token stays on the server:
Because of this change, v2 needs two things v1 did not: a secret to encrypt
what it stores, and a shared storage outside localhost.
This v1 server:
v1 server.ts
v2 src/index.ts
1
Add the new callback URL to the upstream OAuth app
Register
https://<your server>/mcp/oauth/callback next to the v1
/oauth/callback URL. Remove the v1 URL after the v2 deployment is live.2
Create a secret and a shared store
Set
MCP_USE_OAUTH_SECRET to the output of openssl rand -base64 32 on
every instance, and pass a store from mcp-use/oauth/storage. See
OAuth Proxy storage.3
Move token verification into the upstream
Delete
verifyToken. Use github() or google(), or move the user lookup
from verifyToken into upstream({ fetchUser }), which must return a
stable subject.4
Replace upstream token reads in tools
Replace every
ctx.auth.accessToken that calls the upstream API with
upstreamToken(ctx.auth).5
Redirect the v1 endpoint paths
v1 served
/authorize and /token at the root of the server. v2 serves
them under the MCP path, for example /mcp/oauth/authorize. Some clients
saved the v1 URLs when they signed in and keep calling them. Redirect both
paths to the v2 endpoints:src/index.ts
What users see after the upgrade
Each user approves the consent page and signs in with the provider once more, in the same MCP client. Nobody needs to remove the server from a client or add it again:- The client’s next request carries its v1 token, which is the provider’s token. v2 accepts only tokens it issued, so it answers 401.
- The client tries to refresh. v2 does not know the v1 refresh token, so the refresh fails and the client starts a new sign-in.
- The client still sends the
client_idthat v1 gave it, which is the provider’sclientId. v2 accepts it with the redirect URI of the request and shows the consent page. For these clients,ctx.auth.clientIdis the provider’sclientId.
Troubleshooting
Next steps
OAuth Proxy storage
Choose a storage adapter or write your own.
User Context
Use the signed-in user in callbacks.
Mixed Authentication
Serve public and sign-in tools from one endpoint.
API reference
Every
oauthProxy option, type, and default.