# How to authorize a remote MCP server with OAuth from a client

> Walk an MCP client through a protected server's OAuth flow, keep its registration and tokens where you can find them, and recover when the server forgets it.

Source: https://voxgig.com/howto/authorize-a-remote-mcp-server-with-oauth

- Audience: agent-builder
- Level: advanced
- Verified: 2026-10-21
- Published: 2026-10-19

## Short answer

Give the MCP TypeScript SDK's Streamable HTTP transport an OAuthClientProvider that stores its client registration, tokens, and PKCE verifier on disk, and let the SDK walk the specified flow. The 401 names the protected resource metadata, that names the authorization server, the client registers or presents a client ID, and the authorization code exchange with PKCE returns a refresh token. Implement invalidateCredentials so a registration the server has forgotten is dropped instead of retried.

---
## You will need

Node 22 or later, the MCP TypeScript SDK, and Zod, its peer dependency. An MCP server that
publishes [RFC 9728](https://www.rfc-editor.org/rfc/rfc9728.html) metadata and answers 401
with a `WWW-Authenticate` header, and an authorization server with
[RFC 8414](https://www.rfc-editor.org/rfc/rfc8414.html) metadata. Verified 2026-10-21
against Node 22.22.0, `@modelcontextprotocol/sdk` 1.32.1, and `zod` 4.6.7. The sample runs
both servers in one process on 127.0.0.1, approves every authorization for one fixed user,
and calls `fetch` where a desktop client would open the browser.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A static bearer token in client headers](https://code.claude.com/docs/en/mcp) | A server that issues personal access tokens, a job with nobody at a keyboard, or a server with no authorization server behind it | One secret in every copy of the config, no refresh, no per-user identity, and a revocation that stops every copy at once | A person uses the client and the server advertises an authorization server |
| [MCP OAuth 2.1 with dynamic client registration](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization) | A server that advertises an authorization server, and a person who can sign in once | A loopback listener, a browser step, and a cache of registration and tokens that outlives the server's memory of them | The authorization server registers nothing on demand and hands out client IDs by hand |
| [`mcp-remote` holding the credential locally](https://github.com/geelen/mcp-remote) | A client that speaks only stdio, or one whose own OAuth support cannot reach the server | A Node process per server, a token store under `~/.mcp-auth` to clear when a login goes stale, and a flow you debug from its log file | The client runs the flow itself |

The specified flow costs a loopback listener, a browser step, and a credential cache, and
pays with a token that names one person and can be revoked for that person alone. A pasted
token skips all three and leaves one secret in every copy of the config. `mcp-remote` runs
the flow for a client that cannot, in a process of its own, with a cache of its own.

## Let the 401 start the flow

The [authorization specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization)
puts the first move on the server. A request with no token gets a 401 whose
`WWW-Authenticate` header carries `resource_metadata`, the URL of the server's RFC 9728
document, and the scope the request needs.
[RFC 6750](https://www.rfc-editor.org/rfc/rfc6750.html) section 3 adds that a request with
no credentials gets no error code. The resource server below does that, and answers
`invalid_token` to anything expired, unknown, or minted for another resource.

```js title="resource-server.mjs"
    const method = req.method.padEnd(4)
    const [scheme, value] = (req.headers.authorization ?? '').split(' ')
    if (!/^bearer$/i.test(scheme) || !value) {
      // RFC 6750 3: a request with no token gets no error code. The metadata URL and the
      // scope are the whole message, and they are what a client needs to start the flow.
      const header = challenge(res, 401, { resource_metadata: metadataUrl, scope: SCOPE }, 'a bearer token is required')
      return log(`RS  ${method} /mcp  401  WWW-Authenticate: ${header}`)
    }
    const info = verifyToken(value)
    if (!info.active) {
      challenge(res, 401, { error: 'invalid_token', resource_metadata: metadataUrl, scope: SCOPE }, 'the token is unknown, expired, or revoked')
      return log(`RS  ${method} /mcp  401  WWW-Authenticate: Bearer error="invalid_token", resource_metadata=..., scope="${SCOPE}"`)
    }
    // RFC 8707 2: a token minted for another resource is not valid here, however well formed.
    if (info.resource !== canonical) {
      challenge(res, 401, { error: 'invalid_token', resource_metadata: metadataUrl, scope: SCOPE }, `the token was issued for ${info.resource}, this server is ${canonical}`)
      return log(`RS  ${method} /mcp  401  WWW-Authenticate: Bearer error="invalid_token"  (audience ${info.resource})`)
    }
```

The metadata document names the authorization server, and from there the SDK's
[`auth()`](https://ts.sdk.modelcontextprotocol.io/client.html#oauth-client-authentication-helpers)
takes every step. It reads the RFC 8414 metadata, registers under
[RFC 7591](https://www.rfc-editor.org/rfc/rfc7591.html) because that metadata lists a
`registration_endpoint`, and asks for a code with a
[PKCE](https://www.rfc-editor.org/rfc/rfc7636.html) challenge and the
[RFC 8707](https://www.rfc-editor.org/rfc/rfc8707.html) `resource` parameter. The demo
prints each request as the servers see it.

```bash
node demo.mjs
```

```text output
first connection, empty cache
  RS  POST /mcp  401  WWW-Authenticate: Bearer resource_metadata="http://127.0.0.1:38271/.well-known/oauth-protected-resource/mcp", scope="mcp:tools"
  RS  GET  /.well-known/oauth-protected-resource/mcp  200  authorization_servers: [http://127.0.0.1:46253]
  AS  GET  /.well-known/oauth-authorization-server  200  registration_endpoint, code_challenge_methods_supported: [S256]
  AS  POST /register  201  client_id=a283b754-3cf4-446c-91c8-90118803c629  token_endpoint_auth_method=none
  AS  GET  /authorize  302  code_challenge_method=S256  resource=http://127.0.0.1:38271/mcp  ada@example.com approved
  CB  GET  /callback  200  code=442009fd  state and iss present: true
  AS  POST /token  200  grant_type=authorization_code  expires_in=3600  refresh_token issued
  RS  POST /mcp  200  initialize  user ada@example.com
  RS  POST /mcp  202  notifications/initialized  user ada@example.com
  RS  POST /mcp  200  tools/call  user ada@example.com
  whoami -> ada@example.com via client a283b754-3cf4-446c-91c8-90118803c629, scope mcp:tools
  cache file now holds: clientInformation, codeVerifier, discoveryState, tokens
```

Three values in the transcript carry the security of the flow.
`code_challenge_method=S256` binds the code to the client that asked for it. `resource` is
the canonical URL of the MCP server, and the resource server refuses a token bound to any
other URL. `token_endpoint_auth_method=none` records that this client has no secret, because
a client on a laptop cannot keep one.

The transcript shows dynamic registration because this authorization server's metadata
lists a `registration_endpoint` without `client_id_metadata_document_supported`. The
2026-07-28 revision marks that path deprecated in favor of
[Client ID Metadata Documents](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration),
where the client ID is an HTTPS URL to a document the client publishes about itself.[^1]
The SDK takes that path when the server advertises it.

## Finish the browser step on a loopback listener

When the SDK reaches the authorization request it calls the provider's
`redirectToAuthorization` with the URL, and the transport throws `UnauthorizedError`. A
desktop client opens the system browser there; the demo calls `fetch`, which follows the
redirect as a browser would. The client code owns the landing: a loopback listener, the
checks on what arrives, the exchange, and a second connection, because a started transport
cannot be started again.

```js title="client.mjs"
export async function connectWithOAuth(serverUrl, provider, { log = () => {}, name = 'notes-cli', timeoutMs } = {}) {
  const callback = await loopbackCallback({ log, timeoutMs })
  provider.redirectUrl = callback.redirectUrl
  const client = new Client({ name, version: '1.0.0' })
  try {
    const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { authProvider: provider })
    try {
      await client.connect(transport)
      return { client, transport }
    } catch (err) {
      if (!(err instanceof UnauthorizedError)) throw err
    }
    // The SDK has sent the browser to the authorization server and thrown. Wait for the
    // code to arrive on the loopback listener, check it is the answer to this request
    // and from the issuer that was discovered, exchange it, and connect on a new transport.
    const query = await callback.params()
    if (query.has('error')) throw new Error(`authorization refused: ${query.get('error')} (${query.get('error_description') ?? ''})`)
    if (query.get('state') !== provider.expectedState()) throw new Error('state mismatch: the callback is not the answer to this request')
    // RFC 9207 2.4: an iss that is present must be the issuer recorded at discovery, and one
    // that is absent is refused when the server's metadata says it always sends one.
    const metadata = provider.discoveryState()?.authorizationServerMetadata
    const issRequired = metadata?.authorization_response_iss_parameter_supported === true
    if (query.has('iss') ? query.get('iss') !== metadata?.issuer : issRequired) {
      throw new Error(`iss mismatch: ${query.get('iss') ?? 'no iss'} answered, ${metadata?.issuer} was asked`)
    }
    await transport.finishAuth(query.get('code'))
    const authorized = new StreamableHTTPClientTransport(new URL(serverUrl), { authProvider: provider })
    await client.connect(authorized)
    return { client, transport: authorized }
  } finally {
    callback.close()
  }
```

The `state` check ties the callback to the request that started it. The `iss` check is
[RFC 9207](https://www.rfc-editor.org/rfc/rfc9207.html), added in that revision: a client
that recorded the discovered issuer refuses a code from anyone else. It also refuses a code
with no `iss` when the server's metadata says it always sends one, as this server's does.

The listener's port is whatever the operating system had free, so it differs from the one
in the cached registration. [RFC 8252](https://www.rfc-editor.org/rfc/rfc8252.html)
section 7.3 requires an authorization server to allow any port on a loopback redirect
URI,[^2] and the sample's server does.

```js title="authorization-server.mjs"
export function redirectUriMatches(registered, presented) {
  const a = new URL(registered)
  const b = new URL(presented)
  if (a.protocol === 'http:' && LOOPBACK.has(a.hostname)) b.port = a.port
  return a.href === b.href
}
```

## Keep the registration and the tokens in one file

Every value the SDK asks the provider to save goes in one JSON file: the registration, the
tokens, the PKCE verifier for the authorization in progress, and the discovery results. The
file holds a refresh token, so the provider writes it with mode `0600`. The
SDK stamps the registration and the tokens with the `issuer` they came from and refuses to
present them to another authorization server, as the specification requires.

```js title="client.mjs"
export class FileOAuthProvider {
  #file
  redirectUrl

  constructor({ file, openBrowser, clientName = 'notes-cli', forgetOnInvalidClient = true }) {
    this.#file = file
    this.openBrowser = openBrowser
    this.clientName = clientName
    // The SDK calls invalidateCredentials when the authorization server answers
    // invalid_client or invalid_grant, then tries once more from the top. A provider
    // without it keeps the stale entry, which is what pitfall.mjs shows.
    if (forgetOnInvalidClient) this.invalidateCredentials = (scope) => this.forget(scope)
  }
```

A second run from the same file, once the access token has expired, looks like this:

```bash
node demo.mjs
```

```text output
the next morning: access token expired, same cache file
  RS  POST /mcp  401  WWW-Authenticate: Bearer error="invalid_token", resource_metadata=..., scope="mcp:tools"
  AS  POST /token  200  grant_type=refresh_token  expires_in=3600  refresh_token rotated
  RS  POST /mcp  200  initialize  user ada@example.com
  RS  POST /mcp  202  notifications/initialized  user ada@example.com
  RS  POST /mcp  200  tools/call  user ada@example.com
  whoami -> ada@example.com via client a283b754-3cf4-446c-91c8-90118803c629, scope mcp:tools
```

No registration, no authorization request, no browser. The expired token draws an
`invalid_token` challenge, the SDK presents the refresh token, and the authorization server
rotates it, so yesterday's refresh token stops working the moment the replacement is written.
[OAuth 2.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-16) section 4.3 asks
for rotation or sender-constraining whenever the client cannot authenticate, which a public
client cannot.[^3]

## Measure the shortcut

A token pasted into the client's configuration skips the flow. Claude Code takes it as
[`--header`](https://code.claude.com/docs/en/mcp) on `claude mcp add`, and Cursor as a
[`headers` object](https://cursor.com/docs/mcp) in `mcp.json`. VS Code takes a
[`headers` field](https://code.visualstudio.com/docs/agents/reference/mcp-configuration)
whose value can be an `${input:...}` the editor prompts for and stores. The demo mints one
such token for a service identity and hands it to two clients.

```bash
node demo.mjs
```

```text output

the shortcut: one static token pasted into two client configs
  RS  POST /mcp  200  initialize  user ci-bot@example.com
  RS  POST /mcp  202  notifications/initialized  user ci-bot@example.com
  RS  POST /mcp  200  tools/call  user ci-bot@example.com
  laptop       whoami -> ci-bot@example.com via client static-token, scope mcp:tools
  RS  POST /mcp  200  initialize  user ci-bot@example.com
  RS  POST /mcp  202  notifications/initialized  user ci-bot@example.com
  RS  POST /mcp  200  tools/call  user ci-bot@example.com
  build-agent  whoami -> ci-bot@example.com via client static-token, scope mcp:tools
  token revoked at the authorization server
  RS  POST /mcp  401  WWW-Authenticate: Bearer error="invalid_token", resource_metadata=..., scope="mcp:tools"
  laptop       StreamableHTTPError: Streamable HTTP error: Error POSTing to endpoint: {"error":"invalid_token","error_description":"the token is unknown, expired, or revoked"}
  RS  POST /mcp  401  WWW-Authenticate: Bearer error="invalid_token", resource_metadata=..., scope="mcp:tools"
  build-agent  StreamableHTTPError: Streamable HTTP error: Error POSTing to endpoint: {"error":"invalid_token","error_description":"the token is unknown, expired, or revoked"}
```

Both copies are `ci-bot@example.com` to the server, so an audit log cannot tell the laptop
from the build agent. One revocation stops both, and with no `authProvider` on the transport
there is no refresh token to recover with. Claude Code's documentation adds that a
configured `Authorization` header disables its OAuth path for that server.

## Check it worked

Run the suite. Nine tests cover the flow, the refresh, both outcomes of a lost registration,
the PKCE and audience refusals, the static token, and the loopback port rule. Four more cover
the cache file's mode, a missing or foreign `iss`, a sign-in nobody finishes, and a request
from a page on another site.

```bash
node --test client.test.mjs
```

```text output
1..13
# tests 13
# suites 0
# pass 13
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 941.703395
```

The refresh test is the one to read: after the access token expires, the transcript holds
no `/authorize`, no `/register`, and one `/token` request with `grant_type=refresh_token`,
and the cached refresh token changed.

## When it goes wrong

Every connection fails with `invalid_client` after the server was redeployed, and the browser
never opens. The authorization server kept its registrations in memory and lost them; the
client's cache still holds the `client_id` and a refresh token bound to it. The SDK reads the
refusal, calls the provider's `invalidateCredentials`, and starts over. A provider without
that method keeps the stale entry, so the SDK's one retry gets the same answer.

```bash
node pitfall.mjs
```

```text output
registrations the authorization server still knows: 0
a provider without invalidateCredentials, stale cache
  attempt 1  InvalidClientError: unknown client_id
             AS  POST /token  400  error=invalid_client  grant_type=refresh_token
             AS  POST /token  400  error=invalid_client  grant_type=refresh_token
  attempt 2  InvalidClientError: unknown client_id
             AS  POST /token  400  error=invalid_client  grant_type=refresh_token
             AS  POST /token  400  error=invalid_client  grant_type=refresh_token
  the cache file is deleted by hand
  attempt 3  connected, whoami -> ada@example.com via client 8e9732fa-882a-4006-b010-a57ff3ff69ab, scope mcp:tools
             AS  GET  /.well-known/oauth-authorization-server  200  registration_endpoint, code_challenge_methods_supported: [S256]
             AS  POST /register  201  client_id=8e9732fa-882a-4006-b010-a57ff3ff69ab  token_endpoint_auth_method=none
             AS  GET  /authorize  302  code_challenge_method=S256  resource=http://127.0.0.1:41981/mcp  ada@example.com approved
             AS  POST /token  200  grant_type=authorization_code  expires_in=3600  refresh_token issued
a provider with invalidateCredentials, the same stale cache
  attempt 1  connected, whoami -> ada@example.com via client 247bae36-044a-4f1e-b04a-5b9b64293d51, scope mcp:tools
             AS  POST /token  400  error=invalid_client  grant_type=refresh_token
             AS  GET  /.well-known/oauth-authorization-server  200  registration_endpoint, code_challenge_methods_supported: [S256]
             AS  POST /register  201  client_id=247bae36-044a-4f1e-b04a-5b9b64293d51  token_endpoint_auth_method=none
             AS  GET  /authorize  302  code_challenge_method=S256  resource=http://127.0.0.1:41981/mcp  ada@example.com approved
             AS  POST /token  200  grant_type=authorization_code  expires_in=3600  refresh_token issued
```

Until the method is in place, the fix is the file. `mcp-remote` keeps its cache under
`~/.mcp-auth`, or wherever `MCP_REMOTE_CONFIG_DIR` points, and its README says to delete it
and restart the client. Claude Code offers Clear authentication in the `/mcp` menu and
`claude mcp logout <name>` from a shell.

The connection fails with `Incompatible auth server: does not support dynamic client
registration`, the SDK's message when the metadata has no `registration_endpoint`. The fix
is a client ID registered by hand: `--client-id` with `--callback-port` in Claude Code,
`--static-oauth-client-info` for `mcp-remote`, an `auth` object with `CLIENT_ID` in Cursor's
`mcp.json`, and `oauth.clientId` in VS Code's.

## When not to do this

Do not run this flow for a stdio server. The specification says implementations on stdio
should not follow it and should take credentials from the environment, and a child process
has no browser window to open.

Do not paste a token into headers for a server that offers OAuth to the person using the
client. The pasted token names no particular person. Every copy of the config carries it, so
one revocation ends every copy. Reserve it for a job with no person behind it.

Do not keep the in-process token check from the sample. A real resource server verifies a
signed token or asks the authorization server through
[RFC 7662](https://www.rfc-editor.org/rfc/rfc7662.html) introspection, and checks the
audience either way.

Do not put the access token anywhere but the `Authorization` header. The specification
forbids the query string, and a token in a URL ends up in access logs.

## Related how-tos

- [Share one MCP server config across several editors](/howto/share-one-mcp-config-across-editors)

- [Run authorization code with PKCE in a single-page app](/howto/pkce-in-a-single-page-app)

- [Refresh an access token once under concurrent requests](/howto/refresh-a-token-once-under-concurrent-requests)

- [Connect a remote MCP server to Claude Desktop](/howto/connect-a-remote-mcp-server-to-claude-desktop)

## Last verified

Verified 2026-10-21 against Node 22.22.0, `@modelcontextprotocol/sdk` 1.32.1, and `zod`
4.6.7. Every output block is what the command preceding it printed. Both servers ran in one
process on 127.0.0.1 and the browser was `fetch`. Claude Code, Cursor, VS Code, and
`mcp-remote` were not run; their behavior is read from their documentation, and the
specification text is the 2026-07-28 revision.

[^1]: The 2026-07-28 revision
[deprecates dynamic client registration](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration)
in favor of Client ID Metadata Documents, and keeps RFC 7591 for authorization servers that
have not caught up. The draft it defers to,
[the Client ID Metadata Document draft](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/),
is cited at revision 00; the Datatracker holds revision 02, published July 6, 2026. Every
transcript on this page uses the deprecated mechanism, which is the ordinary condition of a
protocol that moves faster than its references.

[^2]: [RFC 8252](https://www.rfc-editor.org/rfc/rfc8252.html) is a best current practice
from October 2017. Section 7.3 requires the authorization server to allow any port on a
loopback redirect URI, because a native client takes whatever port the operating system has
free. Section 8.3 adds that `localhost` is not recommended where the IP literal will do.
Claude Code's documentation records a release that sent `127.0.0.1`, as the RFC prefers,
servers refusing it for not matching a registered `localhost`, and the release two numbers
later restoring `localhost`. The registered string decided.

[^3]: The authorization page lists
[OAuth 2.1](https://datatracker.ietf.org/doc/draft-ietf-oauth-v2-1/) as
`draft-ietf-oauth-v2-1-13`, published May 28, 2025 and expired that November by its own
header. The Datatracker's current revision is 16, published September 3, 2026, still a
draft. The error codes in every transcript on this page, `invalid_client`, `invalid_grant`,
and `invalid_token`, come from [RFC 6749](https://www.rfc-editor.org/rfc/rfc6749.html) and
RFC 6750, both unchanged since 2012.