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.
Table of Contents
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.
| Component | Responsibility |
|---|---|
| Authorization server | Issues, signs, and revokes tokens; runs the login/consent UI; publishes JWKS |
| MCP resource server | Validates tokens, enforces scopes per tool, executes business logic |
| Reverse proxy | Terminates 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.