MCP OAuth Setup Guide: Auth Flows for Remote Servers

By Yanni Papoutsis | 11 min read | 2026-07-25

TL;DR: Local MCP servers running over stdio inherit trust from the process that launched them, so they can read a token from an environment variable and be done. Remote MCP servers reached over HTTP cannot, which is why they use OAuth. The client discovers the server's authorisation requirements, sends the user through an authorisation flow in a browser, receives an authorisation code, exchanges it for tokens, and then attaches an access token to every subsequent request. PKCE is mandatory for public clients and you should treat it as mandatory regardless. The pieces that break are consistent: redirect URIs matched to the character, scopes that changed without re-consent, refresh tokens that were never issued because offline access was not requested, and access tokens that expire mid-session with no refresh path. Design scopes narrow, store refresh tokens like passwords, and make revocation a one-step operation you have actually tested.

Why do remote MCP servers need OAuth at all?

A stdio MCP server is launched as a child process by the client. It runs on your machine, as you, and the client passes it whatever credentials it needs through the environment. There is no network boundary and no ambiguity about who is asking. That model is simple and it works.

A remote MCP server lives somewhere else. It receives requests over HTTP from a client it has never seen, on behalf of a user it must identify, and it needs to know what that user is allowed to do. That is exactly the problem OAuth exists to solve: delegating access to a resource without handing over a password.

So the practical rule is: if your server runs locally and only you use it, an environment variable holding a token is fine. If your server is reachable over the network and serves more than one user, you need OAuth. Anything in between deserves the stricter option.

What does the flow actually look like?

The authorisation code flow with PKCE is what you will use. It goes like this:

  1. Discovery. The client attempts to reach the server and receives an unauthorised response indicating where the authorisation server lives. The client fetches the authorisation server's metadata to learn its endpoints.
  2. Registration. If the client is not pre-registered, it may register dynamically to obtain a client identifier.
  3. Code challenge. The client generates a random verifier, hashes it, and keeps the verifier secret.
  4. Authorisation request. The client opens a browser to the authorisation endpoint with the client ID, requested scopes, a redirect URI, a state value and the code challenge.
  5. User consent. The user signs in and approves the requested scopes.
  6. Redirect. The authorisation server redirects back to the redirect URI with a short-lived authorisation code and the state value echoed back.
  7. Token exchange. The client posts the code and the original verifier to the token endpoint and receives an access token, usually a refresh token, and an expiry.
  8. Resource access. The client sends the access token as a bearer credential on every request to the MCP server.
  9. Refresh. When the access token expires, the client exchanges the refresh token for a new one without involving the user.

Each numbered step has a characteristic failure, which is what makes the flow debuggable. If you know which step you are on, you know which of half a dozen things to check.

Why does PKCE matter?

PKCE, Proof Key for Code Exchange, closes a real attack. Without it, an authorisation code intercepted between the redirect and the token exchange can be redeemed by whoever holds it. With it, the code is useless without the verifier, which never left the client that started the flow.

The mechanism is small:

import base64
import hashlib
import secrets

# Generated at the start of the flow, kept in memory, never transmitted
verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()

# Sent with the authorisation request
challenge = base64.urlsafe_b64encode(
    hashlib.sha256(verifier.encode()).digest()
).rstrip(b"=").decode()

# Authorisation request carries: code_challenge=<challenge>&code_challenge_method=S256
# Token exchange carries: code_verifier=<verifier>

The authorisation server hashes the verifier you present at token exchange and compares it to the challenge it stored. A mismatch means the code came from somewhere else and the exchange is refused.

Use S256. The plain method exists for constrained environments and provides essentially no protection. If your library offers a choice, choose S256 and move on.

Public clients, which includes anything that cannot keep a client secret, must use PKCE. Confidential clients should use it anyway. It costs nothing.

How do I design scopes?

Scopes are the contract between what a user consents to and what your server may do. Get them wrong in the permissive direction and you have built a liability. Get them wrong in the restrictive direction and users hit walls.

The rules that hold up:

One scope per capability class, not per endpoint. calendar.read and calendar.write are useful. Twenty scopes mapping one to one onto API routes are not: nobody can reason about the consent screen and everyone requests all of them.

Separate read from write, always. This is the single most valuable split. It lets a user grant a summarising tool read access without granting it the ability to change anything.

Default to read-only in your documentation and examples. People copy examples. Make the copied thing the safe thing.

Never bundle unrelated capabilities. A scope that grants both reading issues and reading secrets is a scope nobody can safely grant.

Request incrementally. Ask for what the current operation needs, not for everything the server could ever do. Incremental authorisation produces a smaller consent screen and a higher approval rate.

The scope ladder in connect-google-calendar-claude is a good reference model: several tiers, clearly separated, with the narrow one as the recommended starting point.

Where do tokens go?

Access tokens are short-lived and low value. Refresh tokens are long-lived and are effectively a password. Storage strategy should reflect that difference.

What not to do:

# Never: token in a world-readable file next to your code
echo "sk-REPLACE_ME" > ./tokens.json

# Never: token in an environment variable that ends up in a process listing
# or in a shell history file

# Better: OS keychain, with the file store as a locked-down fallback
mkdir -p ~/.config/mcp-auth && chmod 700 ~/.config/mcp-auth
touch ~/.config/mcp-auth/tokens.json && chmod 600 ~/.config/mcp-auth/tokens.json

Add the token path to .gitignore before the file exists. Committing a refresh token and then removing it in a later commit does not undo anything: the object remains in history and may already be cached, mirrored or indexed. The correct response to a committed credential is always revocation, never deletion.

The broader hygiene rules in skill-security-best-practices apply without modification: least privilege, bounded lifetime, rotate on any doubt.

What breaks, and how do I tell which thing broke?

Match the symptom to the step.

redirect_uri_mismatch. The redirect URI your client sent is not in the authorisation server's allow list, compared as an exact string. A trailing slash, a different port, localhost versus 127.0.0.1, http versus https: all different. Copy the URI from the error page rather than retyping it.

invalid_grant at token exchange. Several causes, all at step 7. The code was already used, codes are single-use. The code expired, they live for a minute or two. The PKCE verifier does not match the challenge. The redirect URI in the exchange does not match the one in the authorisation request, it must be identical in both.

invalid_scope. You asked for a scope the authorisation server does not recognise or has not enabled for your client. Check spelling and check whether the scope needs to be registered against the client first.

Authorised but 403 on every call. The token is valid but lacks the scope for what you are doing. Scopes are baked into the token at issue time, so adding a scope to your config changes nothing until the user re-consents and you get a new token.

Works for an hour, then dies. You have an access token and no refresh token. Refresh tokens are typically only issued when you explicitly request offline access, and some providers only issue one on the very first consent for a given client and user. Force a fresh consent to get one.

Refresh token stops working after a week. Common with OAuth apps left in a testing or unverified state. The provider caps refresh token lifetime for unpublished apps. Publishing, or switching to an internal audience where available, removes the cap.

Connector authenticates but shows zero tools. The handshake worked and the capability enumeration did not. If your server calls the upstream API to build its tool list, an expired or under-scoped token produces exactly this. Read the server's stderr for the underlying 401. This pattern is covered end to end in mcp-connector-troubleshooting.

How do I test the flow without a client?

Prove each step in isolation before you blame the integration.

# 1. Can you reach the authorisation server's metadata?
curl -s https://auth.your-provider.example.com/.well-known/oauth-authorization-server | head -40

# 2. Does the token endpoint accept your exchange?
curl -s -X POST https://auth.your-provider.example.com/token \
  -d "grant_type=authorization_code" \
  -d "code=THE_CODE_FROM_THE_REDIRECT" \
  -d "redirect_uri=http://localhost:8080/callback" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "code_verifier=YOUR_VERIFIER"

# 3. Does the access token actually reach the resource?
curl -s -o /dev/null -w "%{http_code}\n" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  https://your-server.example.com/mcp

Use placeholders in anything you save or share. YOUR_CLIENT_ID, YOUR_ACCESS_TOKEN, sk-REPLACE_ME, https://your-project.supabase.co. A working curl command with a live token pasted into it is a credential leak waiting for a shell history file.

If you are implementing the server side rather than consuming it, the structure in build-mcp-server-python-fastmcp gives you somewhere to hang the token validation, and the provider-side view in supabase-auth-google-oauth shows the same flow from the other end.

What should revocation look like?

Test it before you need it. A revocation path you have never exercised is a revocation path that does not work.

The minimum viable answer: you can invalidate a specific refresh token, from a documented procedure, in under five minutes, without taking anything else down. That usually means the provider's third-party access settings or a token revocation endpoint.

Then make it observable. If a token is revoked, your server should fail loudly and clearly rather than degrading into an empty tool list that looks like a bug. A clear 401 token revoked in the logs saves an hour of chasing plumbing that was never broken.

Frequently Asked Questions

Do I need OAuth for a local stdio MCP server?

No. A stdio server is launched as a child process by a client running as you, on your machine, so there is no delegation problem to solve. An API token passed through the config's environment block is appropriate. OAuth becomes necessary when the server is remote, reached over HTTP, and needs to establish which user is calling and what they consented to. Adding OAuth to a purely local server adds failure modes without adding security.

What exactly does PKCE protect against?

Interception of the authorisation code between the redirect and the token exchange. Without PKCE, anyone holding the code can redeem it for tokens. With PKCE, redemption also requires the verifier, a random value that never leaves the client that started the flow. Use the S256 method rather than plain, since plain transmits the verifier in the authorisation request and provides essentially no protection.

Why do I get an access token but never a refresh token?

Most providers only issue a refresh token when you explicitly request offline access, and some only issue one on the first ever consent for a given client and user account. If you consented once, discarded the response, and are now re-authorising, you may receive an access token with no refresh token attached. Force a fresh consent prompt and request offline access explicitly. Without a refresh token, your connector will work for about an hour and then stop.

I added a scope but the server still returns 403. Why?

Scopes are fixed into the access token when it is issued. Changing your configuration to request an additional scope has no effect on tokens that already exist. The user must go through consent again so a new token is issued carrying the new scope. If you are refreshing rather than re-authorising, the refresh will return a token with the original scope set, which is why the 403 persists.

Where should refresh tokens be stored?

In the operating system keychain on a desktop, or a secrets manager on a server. They are long-lived credentials equivalent to a password, and they should never sit in a plain file inside a repository, in a shell environment that leaks into process listings, or in anything that syncs to a shared location. If a refresh token is ever exposed, revoke it at the provider rather than deleting the local copy, because deleting your copy does nothing to invalidate the credential.