How to Deploy a Secure Remote MCP Server with OAuth (2026)

Oct 20, 2026 · 16 min · Ajay Kumar

This guide is for engineers who have an MCP server working locally and now need to put it on the internet without handing every caller the keys. At the end you will have a Streamable HTTP MCP server that acts as an OAuth 2.1 resource server: it publishes RFC 9728 discovery metadata, accepts only JWTs whose audience is its own URL, checks a scope per tool, rate limits per user, writes a JSON audit line per tool call, and runs on Cloud Run. I tested every local step on 20 October 2026 with MCP Python SDK 2.3.0, PyJWT 2.15.1, uvicorn 0.54.0 and Keycloak 26.8.1 in Docker; the outputs below are from those runs. You need Python 3.11 or later, Docker, and for the deploy step a Google Cloud project with gcloud configured. If the concepts are new, read MCP authorization explained first; this post is the hands-on half.

Step 1: Decide the URLs and scopes before writing code

Every OAuth failure I have debugged on an MCP server came back to a string mismatch, so fix the strings first.

Value Example Rule
MCP resource URL https://mcp.example.com/mcp Exact URL clients connect to. No trailing slash. This is the token audience.
Issuer https://auth.example.com/realms/mcp Must match the token's iss byte for byte.
JWKS URL $ISSUER/protocol/openid-connect/certs From the issuer's discovery document (jwks_uri).
Baseline scope notes:read The only scope in scopes_supported. Required on every request.
Elevated scope notes:write Checked per tool, never advertised up front.

The 2026-07-28 MCP authorization spec says scopes_supported should be the minimal set for basic functionality, and clients without a scope hint request everything in it. Keep write and admin scopes out of it.

Step 2: Run Keycloak and create the realm, scopes and audience

Keycloak is a good reference authorization server because its MCP integration page documents exactly which parts of the spec it supports. Two caveats from that page shape the setup: RFC 8707 resource indicators are experimental, so Keycloak ignores the resource parameter unless you enable the resource-indicators feature, and Client ID Metadata Documents (CIMD) are experimental behind --features=cimd. The documented workaround for audience is an optional client scope with an Audience mapper, which is what the script below does.

Start Keycloak locally in dev mode (never start-dev in production; use start with a real hostname, TLS and a database):

docker run -d --name kc-mcp -p 127.0.0.1:8081:8080 \
  -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
  -e KC_BOOTSTRAP_ADMIN_PASSWORD=change-me-local-only \
  quay.io/keycloak/keycloak:26.8.1 start-dev --features=cimd

Expected in docker logs kc-mcp:

WARN  [org.keycloak.common.Profile] (main) Experimental features enabled: cimd:v1
INFO  [io.quarkus] (main) Keycloak 26.8.1 on JVM (powered by Quarkus 3.40.1) started in 7.521s.

Save this as kc-setup.sh. It creates a realm, two client scopes, and an Audience mapper on each so that any token carrying either scope gets aud set to your MCP URL:

#!/usr/bin/env bash
# kc-setup.sh: realm "mcp", scopes notes:read / notes:write, aud = $MCP_URL
set -euo pipefail
KC=/opt/keycloak/bin/kcadm.sh
MCP_URL="${MCP_URL:-https://mcp.example.com/mcp}"
$KC config credentials --server http://localhost:8080 --realm master \
  --user admin --password "${KC_ADMIN_PASSWORD:?set KC_ADMIN_PASSWORD}"
$KC create realms -s realm=mcp -s enabled=true
for SCOPE in notes:read notes:write; do
  ID=$($KC create client-scopes -r mcp -i \
        -s name="$SCOPE" -s protocol=openid-connect \
        -s 'attributes."include.in.token.scope"=true' \
        -s 'attributes."display.on.consent.screen"=true')
  # Audience mapper: tokens with this scope get aud=$MCP_URL
  $KC create "client-scopes/$ID/protocol-mappers/models" -r mcp \
        -s name=mcp-audience -s protocol=openid-connect \
        -s protocolMapper=oidc-audience-mapper \
        -s "config.\"included.custom.audience\"=$MCP_URL" \
        -s 'config."access.token.claim"=true' \
        -s 'config."id.token.claim"=false'
  # Offer it to every client as an optional scope
  $KC update "realms/mcp/default-optional-client-scopes/$ID" -r mcp
done
echo "realm mcp ready, audience $MCP_URL"

Run it inside the container. For local testing the audience is the local server URL:

docker cp kc-setup.sh kc-mcp:/tmp/kc-setup.sh
docker exec -e MCP_URL=http://127.0.0.1:8000/mcp \
  -e KC_ADMIN_PASSWORD=change-me-local-only kc-mcp bash /tmp/kc-setup.sh
Logging into http://localhost:8080 as user admin of realm master
Created new realm with id 'mcp'
Created new model with id '7920c299-242e-488b-ae68-ec7a13b2f3ed'
Created new model with id '75094509-c0bf-490b-ae2d-290cb436f642'
realm mcp ready, audience http://127.0.0.1:8000/mcp

Check the discovery document advertises what MCP clients require. A client must refuse to continue if code_challenge_methods_supported is missing:

curl -s http://127.0.0.1:8081/realms/mcp/.well-known/openid-configuration \
 | python3 -c "import json,sys;d=json.load(sys.stdin);[print(k,'=',d.get(k)) for k in ['issuer','jwks_uri','code_challenge_methods_supported','client_id_metadata_document_supported','authorization_response_iss_parameter_supported']]"
issuer = http://127.0.0.1:8081/realms/mcp
jwks_uri = http://127.0.0.1:8081/realms/mcp/protocol/openid-connect/certs
code_challenge_methods_supported = ['plain', 'S256']
client_id_metadata_document_supported = True
authorization_response_iss_parameter_supported = True

To let MCP clients use CIMD URLs as client IDs, Keycloak also needs a client policy. In the Admin Console: Realm Settings → Client Policies → Profiles → Create client profile, add the executor client-id-metadata-document and fill in Trusted domains (for example the domains of the MCP clients you allow); then Policies → Create client policy, add the condition client-id-uri and associate the profile. Finally create at least one user in the mcp realm so there is someone to log in as.

Step 3: Write the MCP server as a resource server

Install pinned dependencies in a virtualenv:

# requirements.txt
mcp==2.3.0
pyjwt[crypto]==2.15.1
uvicorn==0.54.0

The SDK's authorization docs keep the integration surface small: you implement TokenVerifier.verify_token, pass it with AuthSettings, and the SDK publishes protected resource metadata and returns 401 with WWW-Authenticate for you. token_verifier= and auth= must be passed together. The verifier below validates the JWT locally against the JWKS (signature, iss, aud, exp), so there is no network call per request after the keys are cached.

# server.py  (MCP Python SDK 2.3.0, PyJWT 2.15.1)
"""Remote MCP server: OAuth resource server, per-tool scopes, rate limit, audit log."""
import json
import logging
import os
import time
from collections import defaultdict

import anyio
import jwt
from starlette.requests import Request
from starlette.responses import JSONResponse

from mcp import MCPError
from mcp.server import MCPServer
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken, TokenVerifier
from mcp.server.auth.settings import AuthSettings
from mcp.server.mcpserver.exceptions import ToolError
from mcp.server.transport_security import TransportSecuritySettings

ISSUER = os.environ["OAUTH_ISSUER"]          # https://auth.example.com/realms/mcp
JWKS_URL = os.environ["OAUTH_JWKS_URL"]      # $ISSUER/protocol/openid-connect/certs
MCP_URL = os.environ["MCP_RESOURCE_URL"]     # https://mcp.example.com/mcp (no trailing slash)
PUBLIC_HOST = os.environ["MCP_PUBLIC_HOST"]  # mcp.example.com

log = logging.getLogger("mcp.audit")
logging.basicConfig(level=logging.INFO, format="%(message)s")


class JWTVerifier(TokenVerifier):
    """Validate a JWT access token locally: signature, iss, aud, exp."""

    def __init__(self) -> None:
        self._jwks = jwt.PyJWKClient(JWKS_URL, cache_keys=True, lifespan=300)

    async def verify_token(self, token: str) -> AccessToken | None:
        try:
            # PyJWKClient does blocking HTTP; keep it off the event loop
            key = await anyio.to_thread.run_sync(self._jwks.get_signing_key_from_jwt, token)
            claims = jwt.decode(
                token,
                key.key,
                algorithms=["RS256", "ES256"],  # never "none", never HS* with a JWKS
                issuer=ISSUER,
                audience=MCP_URL,               # token must be minted for this server
                options={"require": ["exp", "iss", "aud", "sub"]},
                leeway=30,
            )
        except jwt.PyJWTError as exc:
            log.info(json.dumps({"event": "token_rejected", "reason": type(exc).__name__}))
            return None
        return AccessToken(
            token=token,
            client_id=claims.get("azp") or claims.get("client_id") or "unknown",
            scopes=claims.get("scope", "").split(),
            expires_at=claims["exp"],
            resource=MCP_URL,  # aud already verified above
            subject=claims["sub"],
            claims={"iss": claims["iss"]},
        )


def require_scope(scope: str) -> AccessToken:
    """Per-tool authorization: refuse the call unless the token carries `scope`."""
    token = get_access_token()
    if token is None or scope not in token.scopes:
        raise ToolError(f"This tool needs the '{scope}' scope. Re-authorize with it and retry.")
    return token


def rate_limit(per_minute: int):
    """Fixed-window limit on tools/call per token subject (per instance)."""
    windows: dict[str, list[float]] = defaultdict(lambda: [0.0, 0])

    async def middleware(ctx, call_next):
        if ctx.method != "tools/call":
            return await call_next(ctx)
        token = get_access_token()
        who = token.subject if token else "anonymous"
        now = time.monotonic()
        window = windows[who]
        if now - window[0] >= 60:
            window[0], window[1] = now, 0
        window[1] += 1
        if window[1] > per_minute:
            raise MCPError(code=-32000, message="Rate limit exceeded, retry in a minute.")
        return await call_next(ctx)

    return middleware


async def audit(ctx, call_next):
    """One JSON line per tool call: who, which client, which tool, outcome, latency."""
    if ctx.method != "tools/call":
        return await call_next(ctx)
    token = get_access_token()
    params = ctx.params or {}
    tool = params.get("name") if isinstance(params, dict) else getattr(params, "name", None)
    start = time.perf_counter()
    outcome = "exception"
    try:
        result = await call_next(ctx)
        is_error = result.get("isError") if isinstance(result, dict) else getattr(result, "is_error", False)
        outcome = "tool_error" if is_error else "ok"
        return result
    except MCPError:
        outcome = "refused"
        raise
    finally:
        log.info(json.dumps({
            "event": "tool_call", "tool": tool, "outcome": outcome,
            "sub": token.subject if token else None,
            "client_id": token.client_id if token else None,
            "ms": round((time.perf_counter() - start) * 1000, 1),
        }))


mcp = MCPServer(
    "notes",
    token_verifier=JWTVerifier(),
    auth=AuthSettings(
        issuer_url=ISSUER,
        resource_server_url=MCP_URL,
        required_scopes=["notes:read"],   # baseline scope for every request
        validate_token_resource=True,
    ),
    middleware=[audit, rate_limit(per_minute=int(os.environ.get("RATE_LIMIT_PER_MIN", "60")))],
)

NOTES: dict[str, list[str]] = defaultdict(list)  # demo storage; use a database


@mcp.tool()
def list_notes() -> list[str]:
    """List the caller's notes."""
    token = require_scope("notes:read")
    return NOTES[token.subject]


@mcp.tool()
def add_note(text: str) -> str:
    """Save a note for the caller."""
    token = require_scope("notes:write")
    NOTES[token.subject].append(text[:2000])
    return "saved"


@mcp.custom_route("/health", methods=["GET"])
async def health(_: Request) -> JSONResponse:
    return JSONResponse({"ok": True})  # unauthenticated by design: liveness only


app = mcp.streamable_http_app(
    transport_security=TransportSecuritySettings(
        allowed_hosts=[PUBLIC_HOST, f"{PUBLIC_HOST}:*", "localhost:*", "127.0.0.1:*"],
        allowed_origins=[],
    ),
)

Three choices in that file are deliberate. The audience check lives in the verifier and validate_token_resource=True double-checks it, because accepting a token minted for another service is the mistake the spec's token passthrough section exists to prevent. Data is keyed by sub, never by anything the client sends. And transport_security is not optional: the SDK's deploy docs explain that without it the app only accepts localhost Host headers and answers everything else with 421.

Per-tool scopes here return a tool error the model can read. The spec's preferred signal for a missing scope is an HTTP 403 with error="insufficient_scope", which triggers step-up in the client; the SDK emits that only for required_scopes, so if you want HTTP-level step-up per tool, enforce it at a gateway that can read the 2026-07-28 Mcp-Method and Mcp-Name request headers. The SDK marks its middleware list as provisional, so pin the SDK version.

Step 4: Test discovery, rejection and tool calls locally

Start the server against the local realm:

export OAUTH_ISSUER=http://127.0.0.1:8081/realms/mcp
export OAUTH_JWKS_URL=$OAUTH_ISSUER/protocol/openid-connect/certs
export MCP_RESOURCE_URL=http://127.0.0.1:8000/mcp
export MCP_PUBLIC_HOST=mcp.example.com
uvicorn server:app --host 127.0.0.1 --port 8000

Discovery and the unauthenticated path:

curl -s http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp
curl -si -X POST http://127.0.0.1:8000/mcp -H 'content-type: application/json' -d '{}'
{"resource":"http://127.0.0.1:8000/mcp","authorization_servers":["http://127.0.0.1:8081/realms/mcp"],"scopes_supported":["notes:read"],"bearer_methods_supported":["header"]}
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"

For a scripted token, create a throwaway confidential client with a service account in the mcp realm (Clients → Create client, Client authentication on, Service accounts roles on, add notes:read and notes:write as optional client scopes) and request a token:

TOKEN=$(curl -s http://127.0.0.1:8081/realms/mcp/protocol/openid-connect/token \
  -d grant_type=client_credentials -d client_id=smoke-test \
  -d client_secret="$SMOKE_SECRET" -d "scope=notes:read notes:write" \
  | python3 -c "import json,sys;print(json.load(sys.stdin)['access_token'])")

The decoded claims that matter:

{'iss': 'http://127.0.0.1:8081/realms/mcp', 'aud': 'http://127.0.0.1:8000/mcp', 'azp': 'smoke-test', 'scope': 'notes:read notes:write'}

Now a client. In SDK 2.x headers go on an httpx2.AsyncClient you pass to the transport:

# smoke.py: python smoke.py http://127.0.0.1:8000/mcp "$TOKEN"
import asyncio, sys
import httpx2
from mcp import Client
from mcp.client.streamable_http import streamable_http_client

async def main(url: str, token: str) -> None:
    async with httpx2.AsyncClient(headers={"Authorization": f"Bearer {token}"},
                                  timeout=httpx2.Timeout(30.0, read=300.0)) as http:
        async with Client(streamable_http_client(url, http_client=http)) as client:
            print("tools:", [t.name for t in (await client.list_tools()).tools])
            r = await client.call_tool("add_note", {"text": "rotate the JWKS"})
            print("add_note:", r.is_error, r.content[0].text)

asyncio.run(main(sys.argv[1], sys.argv[2]))

With a token carrying both scopes, then with one carrying only notes:read:

tools: ['list_notes', 'add_note']
add_note: False saved

tools: ['list_notes', 'add_note']
add_note: True Error executing tool add_note: This tool needs the 'notes:write' scope. Re-authorize with it and retry.

The server log shows the audit lines, and a token minted for another audience never reaches a tool:

{"event": "tool_call", "tool": "add_note", "outcome": "ok", "sub": "fd3ca118-942e-4ee5-b2e9-17d3b2d3dd2c", "client_id": "smoke-test", "ms": 0.6}
{"event": "token_rejected", "reason": "InvalidAudienceError"}
INFO:     127.0.0.1:64265 - "POST /mcp HTTP/1.1" 401 Unauthorized

With RATE_LIMIT_PER_MIN=3, the fourth call in a minute came back as a JSON-RPC error and logged "outcome": "refused". For an interactive login test, npx @modelcontextprotocol/inspector@2.11.0 walks the full browser flow against the server.

What happens on the first call to the server 1. POST /mcp, no token 401 + WWW-Authenticate resource_metadata=... 2. Protected resource metadata authorization_servers: Keycloak realm issuer 3. Issuer metadata PKCE S256 advertised? CIMD supported? 4. User logs in client_id = CIMD https URL PKCE + resource=MCP URL 5. Keycloak issues JWT aud = MCP URL (scope mapper) scope = notes:read 6. Server verifies sig, iss, aud, exp, scope rate limit, audit log line Wrong aud or expired token: 401 again, logged as token_rejected. Missing notes:write: tool error. Wrong Host header: 421 before auth runs. Fourth call in a minute at limit 3: JSON-RPC error.
The discovery and validation path implemented in Steps 2 to 4, following the 2026-07-28 MCP authorization spec; the outcomes in the dashed box are from the local test runs above.

Step 5: Containerize and deploy to Cloud Run

Cloud Run terminates TLS and forwards plain HTTP, so uvicorn needs --proxy-headers; the SDK docs warn that without it redirects point at http:// and the Python client refuses to follow a downgrade.

# Dockerfile
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY server.py .
USER nobody
# Cloud Run sets $PORT; only Google's front end can reach the container
CMD exec uvicorn server:app --host 0.0.0.0 --port ${PORT:-8080} \
    --proxy-headers --forwarded-allow-ips='*'

Use --allow-unauthenticated because authentication happens in the app with your IdP's tokens; Cloud Run IAM would otherwise demand Google-signed identity tokens that MCP clients do not have. Deploy once, read the URL, then set the values that depend on it:

REGION=europe-west1
gcloud run deploy notes-mcp --source . --region "$REGION" \
  --allow-unauthenticated --max-instances 3 --concurrency 40 \
  --set-env-vars OAUTH_ISSUER=https://auth.example.com/realms/mcp,OAUTH_JWKS_URL=https://auth.example.com/realms/mcp/protocol/openid-connect/certs,MCP_RESOURCE_URL=https://placeholder.invalid/mcp,MCP_PUBLIC_HOST=placeholder.invalid

URL=$(gcloud run services describe notes-mcp --region "$REGION" --format='value(status.url)')
HOST=${URL#https://}
gcloud run services update notes-mcp --region "$REGION" \
  --update-env-vars MCP_RESOURCE_URL=$URL/mcp,MCP_PUBLIC_HOST=$HOST
curl -s "$URL/.well-known/oauth-protected-resource/mcp"

Then rerun kc-setup.sh (or edit the two Audience mappers) with MCP_URL=$URL/mcp, because the token audience must now be the public URL. If you map a custom domain, the custom URL becomes the resource URL and the audience, and the run.app URL should stop being advertised.

Two production notes. The rate limiter is per instance, so the effective ceiling is the limit times --max-instances; for a hard global limit put it in Redis or at an API gateway. And Cloud Logging parses one-line JSON on stdout, so the audit lines are queryable by sub and tool without extra setup.

Using Auth0, Entra ID or Cognito instead of Keycloak

Only the verifier changes. The SDK docs say to keep validate_token_resource=True when your authorization server binds tokens to the resource the client sent, and to set it to False and check aud yourself when the IdP uses its own audience identifiers, such as an Auth0 API identifier or an Entra application ID.

  • Entra ID: per the access token claims reference, v2.0 tokens carry the API's client ID in aud, delegated scopes in scp, and app permissions in roles. Validate aud against the client ID and read scopes from scp.
  • Cognito: the access token docs say aud appears only when the app requested a resource binding, and client_id names the app client. Request the binding, or your audience check has nothing to check.
  • Auth0: create an API whose identifier is your MCP URL so aud matches without code changes. Confirm against a real token before trusting it.

Troubleshooting

Symptom Likely cause Fix
Every request returns 421 Misdirected Request, Invalid Host header in server log Host not in allowed_hosts Add the public hostname (and host:*) to TransportSecuritySettings
401 with a token that looks valid; log shows InvalidAudienceError aud is not the exact MCP URL (trailing slash, run.app vs custom domain) Make the Audience mapper, MCP_RESOURCE_URL and client URL identical
401; log shows InvalidIssuerError Keycloak hostname differs between token and config (internal vs public URL) Set Keycloak's public hostname; copy issuer from discovery exactly
401; log shows PyJWKClientError JWKS URL unreachable from the container, or key rotated Check egress to the IdP; confirm jwks_uri from discovery
Client says it cannot find an authorization server PRM missing or wrong path behind a proxy curl /.well-known/oauth-protected-resource/mcp; forward /.well-known/* through any gateway
403 insufficient_scope on every request Token lacks the baseline notes:read Ensure the client requests it and the scope is optional for that client in Keycloak
Client aborts before the browser opens IdP metadata lacks code_challenge_methods_supported Use an IdP that advertises S256; MCP clients must refuse otherwise
Login page rejects a URL client_id CIMD not enabled or domain not trusted Start Keycloak with --features=cimd and add a client policy with trusted domains
Redirect to http://... refused by the Python client uvicorn behind TLS proxy without proxy headers Add --proxy-headers --forwarded-allow-ips
Tool works for one user, others see their data State keyed by client-supplied value Key storage on the verified sub only

FAQ

Can I use an API key instead of OAuth for a remote MCP server?

You can, and for a single internal caller it is sometimes the honest choice. But clients that implement the MCP spec discover and run OAuth automatically from the 401, and an API key gives you no per-user identity, no expiry and no scopes. For anything multi-user, use OAuth.

Should the MCP server call token introspection or validate JWTs locally?

Local JWT validation is faster and has no per-request dependency on the IdP; introspection (RFC 7662) lets you revoke tokens instantly. Short-lived access tokens of five to fifteen minutes with local validation is the common compromise. The SDK ships an introspection verifier example in examples/servers/simple-auth.

Do I need sticky sessions on Cloud Run?

Not for 2026-07-28 clients, which have no protocol session. Older clients on 2025-11-25 or earlier use Mcp-Session-Id held in one instance's memory, so either enable session affinity or run the legacy path stateless, as the SDK deploy docs describe.

Can my MCP server forward the user's token to a downstream API?

No. The spec forbids token passthrough. If a tool calls GitHub or a database API, the server gets its own token for that API, through a separate OAuth flow or a workload identity, and maps the verified sub to it.

In production the first thing I check on any MCP server is the negative case: mint a token for a different audience and confirm the server returns 401, because that single test catches more real misconfigurations than everything else in this guide combined. After that I look at whether write scopes are absent from scopes_supported, whether audit lines carry the verified sub, and whether the Host allowlist matches the public name. The authoritative references are the MCP authorization spec, the Python SDK authorization and deploy pages, and Keycloak's MCP page. If you are still building the server itself, start with building an MCP server in Python or TypeScript, and for the attack side read MCP security in 2026.

Related: MCP Security in 2026: The Protocol Got Hardened. The Ecosystem Didn't., Who Is This Agent? Non-Human Identity in 2026, From the RFCs to the Breaches and Policy as Code for AI Agents in 2026: Cedar at the Gateway, Rego in the Hook, and the 11.2% the Model Still Lets Through.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related