//------------------------------------------------------------------- //-------------------------------------------------------------------
secure MCP server with OAuth 2.1

How to Secure a Remote MCP Server with OAuth 2.1, Token Audience Validation, and Reverse Proxy Controls

A remote Model Context Protocol (MCP) server is an unauthenticated HTTP endpoint by default, so if you want to secure MCP server with OAuth 2.1, you need a layered plan that covers discovery, token validation, and network controls before any AI client ever connects.

This guide turns the official MCP authorization rules into a simple step-by-step checklist, from setting up your VPS to checking token logs once you’re live.

Why Remote MCP Servers Need OAuth 2.1

A local MCP server is protected by your operating system, since only your own machine can reach it. A remote MCP server is different; anyone with the URL can access it over the internet, so the MCP specification requires OAuth 2.1 for any public deployment. Without it, attackers can drain your API limits, run tools that write or delete data, or steal whatever information your tools can reach.

OAuth 2.1 with OIDC is the minimum requirement for any server that’s accessible over a network.

Architecture: Separate the Resource Server from the Auth Server

To secure MCP server with OAuth 2.1 correctly, keep your MCP server focused only on checking tokens. Let a separate authorization server, like Keycloak, Auth0, ScaleKit, or a self-hosted OIDC provider, handle logins and issue the tokens. This way, your MCP code never touches passwords or login screens; it just checks the Authorization: Bearer <token> header on incoming requests.

ComponentResponsibility
Authorization serverIssues, signs, and revokes tokens; runs the login/consent UI; publishes JWKS
MCP resource serverValidates tokens, enforces scopes per tool, executes business logic
Reverse proxyTerminates TLS, rate-limits, filters IPs, caps request size, logs access

Step 1. Set Up an Isolated VPS

Start with a dedicated Linux VPS instead of sharing a server with other projects. If one part gets compromised, keeping things separate stops the damage from spreading. An isolated VPS with a dedicated firewall and access-control rules works well for this. Setting this up is your first step to secure MCP server with OAuth 2.1 on infrastructure you fully control.

On a fresh Linux VPS running Ubuntu 22.04 or newer, run:

sudo apt update && sudo apt upgrade -y
sudo useradd -m -s /bin/bash mcpadmin
sudo usermod -aG sudo mcpadmin
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 443/tcp
sudo ufw enable

You cannot secure an MCP server with OAuth 2.1 without HTTPS everywhere, so get certificates with Certbot for your domain name:

sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d mcp.example.com -d auth.example.com

Step 2. Deploy the MCP server in Docker

It is recommended to run the MCP server itself in a container so it can be redeployed and isolated. You can check this guide on deploying a remote MCP server with Docker for the full container setup.

# docker-compose.yml
services:
  mcp-server:
    image: my-mcp-server:latest
    restart: unless-stopped
    environment:
      - MCP_RESOURCE_URL=https://mcp.example.com/mcp
      - OAUTH_ISSUER=https://auth.example.com
      - OAUTH_JWKS_URI=https://auth.example.com/.well-known/jwks.json
    ports:
      - "127.0.0.1:8080:8080"
    networks:
      - internal
networks:
  internal:
    driver: bridge

Note: Binding to 127.0.0.1 keeps the container reachable only through the reverse proxy, never directly from the internet.

Step 3. Run Keycloak as the Authorization Server

Keycloak is the part that handles logins and hands out tokens, so it needs to run alongside your MCP server before anything else can work.

Add it right next to your MCP server:

# add to docker-compose.yml
  keycloak:
    image: quay.io/keycloak/keycloak:latest
    command: start --optimized
    environment:
      - KEYCLOAK_ADMIN=admin
      - KEYCLOAK_ADMIN_PASSWORD=change_this_password
      - KC_HOSTNAME=auth.example.com
      - KC_PROXY=edge
    ports:
      - "127.0.0.1:8081:8080"
    networks:
      - internal

Bring it up:

docker compose up -d keycloak

Open your browser and go to:

https://auth.example.com/admin

Log in with your admin account. Create a new realm, then add a client for your MCP server. Turn on PKCE (S256 only), and turn off the implicit and password grant types; MCP only needs Authorization Code with PKCE plus refresh tokens.

Step 4. Publish Protected-Resource File

This file lives on your MCP server’s domain, tells clients how to log in, and is the entry point they use to secure MCP server with OAuth 2.1 automatically. Following RFC 9728, put it at:

https://mcp.example.com/.well-known/oauth-protected-resource

The file itself just describes your server:

{
  "resource": "https://mcp.example.com/mcp",
  "authorization_servers": ["https://auth.example.com"],
  "scopes_supported": ["mcp:connect", "tools:read", "tools:write"],
  "bearer_methods_supported": ["header"]
}

In FastAPI, serve it with a simple route:

from fastapi import FastAPI
from fastapi.responses import JSONResponse

app = FastAPI()

@app.get("/.well-known/oauth-protected-resource")
async def protected_resource_metadata():
    return JSONResponse({
        "resource": "https://mcp.example.com/mcp",
        "authorization_servers": ["https://auth.example.com"],
        "scopes_supported": ["mcp:connect", "tools:read", "tools:write"],
        "bearer_methods_supported": ["header"]
    })

When someone connects without a token, your server should reply with a 401 error and point them to that metadata file. This is required by RFC 9728, it’s how clients know where to log in:

WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"

Step 5. Check the Authorization Server Metadata

Keycloak already publishes this file for you at:

https://auth.example.com/.well-known/oauth-authorization-server

Test it works:

curl https://auth.example.com/.well-known/oauth-authorization-server

You should see the login URL, token URL, and supported features listed back in JSON.

Step 6. Register Your Client

If unknown clients will connect, let them register themselves instead of manually creating credentials for each one:

curl -X POST https://auth.example.com/register \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "My MCP Client",
    "redirect_uris": ["https://client.example.com/callback"],
    "grant_types": ["authorization_code", "refresh_token"],
    "token_endpoint_auth_method": "none"
  }'

Step 7. Run the Login Flow with PKCE

PKCE stops a stolen login code from being reused by an attacker, and it’s required for every OAuth 2.1 flow in MCP. First, generate a secret code and its hash:

CODE_VERIFIER=$(openssl rand -base64 32 | tr -d '=+/' | cut -c1-43)
CODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 | tr -d '=' | tr '+/' '-_')

Now send the user to log in, and make sure to include the resource parameter. This tells the authorization server exactly which server the token is for. It’s what lets you secure MCP server with OAuth 2.1 against tokens meant for other services:

https://auth.example.com/authorize?
  response_type=code&
  client_id=CLIENT_ID&
  redirect_uri=https://client.example.com/callback&
  code_challenge=CODE_CHALLENGE&
  code_challenge_method=S256&
  state=RANDOM_STRING&
  resource=https://mcp.example.com/mcp

After login, exchange the returned code for real tokens:

curl -X POST https://auth.example.com/token \
  -d grant_type=authorization_code \
  -d code=AUTH_CODE_FROM_REDIRECT \
  -d redirect_uri=https://client.example.com/callback \
  -d client_id=CLIENT_ID \
  -d code_verifier=$CODE_VERIFIER \
  -d resource=https://mcp.example.com/mcp

Step 8. Check Tokens on Every Request

Every JWT check you add to secure MCP server with OAuth 2.1 must validate the issuer, audience, expiry, and signature together, not separately:

def validate_token(token: str, required_scope: str):
    claims = jwt.decode(
        token,
        key=get_jwks_public_key(token),
        algorithms=["RS256", "ES256"],
    )
    assert claims["iss"] == "https://auth.example.com"
    assert "https://mcp.example.com/mcp" in claims["aud"]
    assert claims["exp"] > time.time()
    assert required_scope in claims.get("scope", "").split()
    return claims

Attach this into every request, not just the first one:

from fastapi import Request, HTTPException

@app.middleware("http")
async def auth_middleware(request: Request, call_next):
    auth_header = request.headers.get("authorization", "")
    if not auth_header.startswith("Bearer "):
        return JSONResponse(
            status_code=401,
            headers={"WWW-Authenticate": 'Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"'},
            content={"error": "unauthorized"},
        )
    token = auth_header.removeprefix("Bearer ")
    try:
        request.state.claims = validate_token(token, required_scope="mcp:connect")
    except Exception:
        raise HTTPException(status_code=401, detail="invalid token")
    return await call_next(request)

Step 9. Give Each Tool Its Own Scope

Scopes let you secure MCP server with OAuth 2.1 at the level of individual tools, so a token that only needs to read data should never be allowed to delete anything:

TOOL_SCOPE_MAP = {
    "list_files": "tools:read",
    "deploy_service": "tools:deploy-preview",
    "delete_record": "tools:write",
}

def authorize_tool_call(claims, tool_name):
    required = TOOL_SCOPE_MAP[tool_name]
    if required not in claims["scope"].split():
        raise PermissionError(f"missing scope: {required}")

Step 10. Keep Tokens Short and Use Refresh Tokens

Refresh tokens let you secure MCP server with OAuth 2.1 without forcing constant re-logins, while access tokens stay short-lived:

curl -X POST https://auth.example.com/token \
  -d grant_type=refresh_token \
  -d refresh_token=YOUR_REFRESH_TOKEN \
  -d client_id=CLIENT_ID

Keycloak rotates the refresh token automatically each time it’s used, so a stolen old one gets rejected.

Step 11. Never Forward Tokens Downstream

Token passthrough is the most common way teams fail to secure MCP server with OAuth 2.1. It means taking the client’s token and forwarding it straight to another service. This is banned in the MCP spec, and it breaks your audit trail:

# WRONG — never do this
headers = {"Authorization": request.headers["Authorization"]}
requests.get(downstream_api_url, headers=headers)

# RIGHT — get your own token for the downstream service
obo_token = oauth_client.exchange_on_behalf_of(
    assertion=inbound_token,
    scope="downstream:minimal-scope",
)
requests.get(downstream_api_url, headers={"Authorization": f"Bearer {obo_token}"})

Step 12. Lock Down the Reverse Proxy

A reverse proxy is what lets you secure MCP server with OAuth 2.1 with rate limiting, IP filtering, and request-size caps in one place:

limit_req_zone $binary_remote_addr zone=mcp_limit:10m rate=10r/s;

server {
    listen 443 ssl;
    server_name mcp.example.com;

    client_max_body_size 2m;

    location /mcp {
        limit_req zone=mcp_limit burst=20 nodelay;

        allow 203.0.113.0/24;
        deny all;

        proxy_pass http://127.0.0.1:8080;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        access_log /var/log/nginx/mcp_access.log combined;
    }

    location = /.well-known/oauth-protected-resource {
        proxy_pass http://127.0.0.1:8080;
        add_header Content-Type application/json;
    }
}
sudo nginx -t && sudo systemctl reload nginx

Limiting request size is a small step, but it matters because it stops attackers from overwhelming your server’s memory.

To secure MCP server with OAuth 2.1 more fully, pair this with a stronger bot-filtering layer in front of Nginx, like protecting self-hosted apps with CrowdSec.

Step 13. Turn on Audit Logging

Audit logging closes the loop and lets you secure MCP server with full visibility into every token and tool call:

logger.info(json.dumps({
    "event": "tool_invocation",
    "subject": claims["sub"],
    "scope_used": required_scope,
    "tool": tool_name,
    "decision": "allow",
    "correlation_id": request_id,
    "timestamp": time.time(),
}))

Step 14. Test Everything Before Going Live

Testing the full flow is the last step to secure MCP server with OAuth 2.1 before real users connect. Run through each check with your live domain:

# 1. No token should return 401 with the right header
curl -i https://mcp.example.com/mcp

# 2. Metadata file should be reachable
curl https://mcp.example.com/.well-known/oauth-protected-resource

# 3. Auth server metadata should be reachable
curl https://auth.example.com/.well-known/oauth-authorization-server

# 4. A valid token should work
curl -H "Authorization: Bearer $ACCESS_TOKEN" https://mcp.example.com/mcp

# 5. An expired or wrong-audience token should be rejected
curl -H "Authorization: Bearer $BAD_TOKEN" https://mcp.example.com/mcp

# 6. Oversized body should be blocked by Nginx
curl -X POST --data-binary @big_file.bin https://mcp.example.com/mcp

# 7. Rapid requests should trigger rate limiting
for i in {1..20}; do curl https://mcp.example.com/mcp; done

Conclusion

Securing a remote MCP server isn’t just one login screen; it’s several checks working together: a metadata file, strict audience checks, short-lived tokens, a locked-down proxy, and full logging. Keep the whole setup on its own isolated server so one weak spot can’t bring down everything else.

We hope you enjoy this guide. For more detailed information, check the official MCP Authorization specification.

FAQs

Do I need OAuth 2.1 for a local MCP server?

No, a local server is already protected by your operating system since only you can reach it.

What is token audience validation?

It’s checking that a token’s aud field matches your exact MCP server URL, so tokens meant for other services get rejected.

How long should access tokens last?

A few minutes, backed by a refresh token so users don’t need to log in constantly.

Is PKCE required for securing MCP?

Yes, every OAuth 2.1 flow in MCP must use PKCE with SHA-256.

Post Your Comment

PerLod delivers high-performance hosting with real-time support and unmatched reliability.

Contact us

Payment methods

payment gateway
Perlod Logo
Privacy Overview

This website uses cookies so that we can provide you with the best user experience possible. Cookie information is stored in your browser and performs functions such as recognising you when you return to our website and helping our team to understand which sections of the website you find most interesting and useful.