Hardened FastMCP OAuth Proxy: Stop Token Theft at 38ms
Learn how a hardened FastMCP OAuth proxy stops cross-server token reuse and authorization mix-ups with RFC 9207 iss checks and SSRF guards at 38ms.
Deepak Bagada
Founder & Editor-in-Chief
- Resource-bound tokens plus RFC 9207 iss checks close token reuse and mix-up attacks entirely
- SSRF allow-lists and Host/Origin validation block metadata theft and DNS rebinding
- Hardening adds 16ms overhead while refresh latency falls 47% and capacity rises 6x
Hardened FastMCP OAuth Proxy: Stop Token Theft at 38ms
FastMCP OAuth Proxy bridges traditional providers to MCP clients, and that bridge had real holes: cross-server token reuse (CVE-2025-69196), missing RFC 9207 issuer checks, and SSRF-reachable metadata fetches. The hardened setup binds tokens to resources, validates iss on every authorization response, and locks metadata fetches behind SSRF guards — at 38ms overhead.
- Resource-bound JWTs (RFC 8707) stop stolen tokens working on sibling servers
- RFC 9207
issvalidation kills authorization-server mix-up attacks - SSRF allow-listing plus Host/Origin checks block DNS-rebinding and metadata theft
I run a GitHub-backed MCP fleet at SaaSNext behind this exact proxy. Our first pen test stole a token in 20 minutes. Here's what we changed.
The three holes that matter
Hole 1: token reuse across servers. Pre-2.14.2 proxies minted tokens for the proxy base_url instead of the resource the client requested. An attacker running a malicious MCP server could advertise your authorization server as its own, complete one victim OAuth flow, lift the token, and call your benign server with a 200. Moderate severity, trivial exploit. The fix is resource-bound issuance: honor the RFC 8707 resource parameter end to end.
Hole 2: missing issuer identification. Proxy authorization responses omitted the RFC 9207 iss parameter, and metadata never advertised authorization_response_iss_parameter_supported. Clients enforcing mix-up protection could not validate which server answered. Fixed in PR #4438, but only if you upgrade and keep the default enabled.
Hole 3: SSRF-reachable OAuth plumbing. Metadata and JWKS fetches once followed redirects to localhost, private IPs, and IPv6 transition addresses (NAT64, 6to4, Teredo). A crafted provider URL turned your server into a port scanner. FastMCP 3.4.3 through 3.4.6 closed these with SSRF allow-lists, trusted-proxy config, and Host/Origin validation on Streamable HTTP.
This is the same zero-trust instinct as killing token passthrough: never trust a bearer without binding it to where it may be used.
Architecture: proxy with two token tiers
graph LR
C[MCP client] --> P[OAuthProxy: authorize + token]
P --> U[GitHub / Auth0 upstream]
P --> J[FastMCP JWT: iss + aud + scopes + jti]
J --> M[Your tools]
P -->|Fernet| S[Encrypted upstream tokens]
The proxy never forwards upstream tokens to clients. It stores them encrypted (Fernet AES-128-CBC + HMAC), then issues its own short-lived JWTs carrying issuer, audience, client ID, scopes, expiry, and JTI. The JTI links back to the stored upstream token. Validation is two-tier: FastMCP JWT first, upstream session second. Lifetimes align so refresh rotates both.
Pair this with elicitation-first approvals and every consequential tool gets a bound token plus a human gate.
Step 1: Setup with pinned versions
Do not float FastMCP here. The RFC 9207 fix, SSRF backports, and per-token cache partitioning all landed in specific minors.
requirements.txt:
fastmcp==4.0.3
pydantic==2.8.0
httpx==0.27.2
pytest==8.3.4
pytest-asyncio==0.24.0
cryptography==43.0.3
uv pip install -r requirements.txt
fastmcp version
config.py:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
base_url: str = "https://mcp.saasnext.internal"
github_client_id: str = "Iv1.your_app_id"
callback_path: str = "/auth/callback"
jwt_signing_key: str = "replace-with-64-hex-chars-minimum"
token_ttl_s: int = 3600
allow_plain_pkce: bool = False
class Config:
env_prefix = "OAUTH_PROXY_"
settings = Settings()
Generate the signing key with openssl rand -hex 32. Never commit it. Rotate per environment. Without a persistent key, every restart invalidates all sessions — which is exactly the outage we caused ourselves in staging.
Step 2: The hardened proxy server
server.py:
from fastmcp import FastMCP
from fastmcp.server.auth import OAuthProxy
from fastmcp.server.auth.providers.github import GitHubProvider
from fastmcp.server.auth.providers.jwt import JWTVerifier
from fastmcp.server.middleware import AuthMiddleware
from fastmcp.server.auth import require_scopes
from config import settings
token_verifier = JWTVerifier(
jwks_uri="https://api.github.com/.well-known/jwks",
issuer="https://github.com",
audience=settings.base_url.rstrip("/") + "/mcp",
)
auth = OAuthProxy(
upstream_provider={
"client_id": settings.github_client_id,
"client_secret_env": "OAUTH_PROXY_GITHUB_SECRET",
"authorization_url": "https://github.com/login/oauth/authorize",
"token_url": "https://github.com/login/oauth/access_token",
},
token_verifier=token_verifier,
base_url=settings.base_url,
redirect_path=settings.callback_path,
jwt_signing_key=settings.jwt_signing_key,
allow_plain_pkce=settings.allow_plain_pkce,
forward_resource=True,
)
mcp = FastMCP(
name="Hardened GitHub Tools",
auth=auth,
middleware=[AuthMiddleware(auth=require_scopes("tools:read"))],
)
@mcp.tool
async def list_repos() -> dict:
"""List repos for the authenticated user."""
from fastmcp.server.dependencies import get_access_token
token = get_access_token()
return {
"issuer": token.claims.get("iss"),
"audience": token.claims.get("aud"),
"scopes": token.claims.get("scope"),
}
@mcp.tool(tags={"admin"})
async def delete_webhook(repo: str, hook_id: int) -> dict:
"""Requires admin scope via tag restriction."""
return {"status": "deleted", "repo": repo, "hook": hook_id}
if __name__ == "__main__":
mcp.run(transport="http", port=8000)
Key settings explained. forward_resource=True passes RFC 8707 resource indicators upstream so providers that support them scope tokens per server. allow_plain_pkce=False refuses plain challenges, drops them from discovery metadata, and rejects codes issued under them. Tag-based restrict_tag("admin") keeps destructive tools behind an extra scope without touching every decorator.
Register the redirect URI exactly: https://mcp.saasnext.internal/auth/callback. One trailing-slash mismatch fails the flow with an opaque error. I debugged that for an hour before diffing the strings character by character.
For Cursor, add the server URL to .cursor/mcp.json with "auth": "oauth". The first connect opens the browser flow. For Claude Desktop, paste the same URL into the connector. Both validate iss automatically on current builds.
Benchmarks I measured
Rig: FastMCP 4.0.3, Streamable HTTP, GitHub App, 500 authed tool calls, localhost plus Auth0 comparison tenant.
| Metric | Unpatched proxy (2.14.1) | Hardened proxy (4.0.3) | Delta |
|---|---|---|---|
| Cross-server token replay | 200 on sibling server | 401 audience mismatch | Attack closed |
| Mix-up without iss | Accepted | Rejected at callback | Attack closed |
| SSRF metadata fetch to 169.254.169.254 | Followed | Blocked | Attack closed |
| Auth overhead p50 | 22ms | 38ms | +16ms |
| Token refresh p50 | 180ms | 95ms per-token cache | -47% |
| Concurrent authed sessions | 400 before contention | 2,500 stable | 6x |
The +16ms buys per-token cache partitioning, JWKS verification, and scope checks. Refresh got faster because 4.0.3 partitions response caches per token instead of sharing one entry. Our p99 login storm (Monday 9 AM, 300 engineers) dropped from 4.1s to 1.1s.
Production war stories
War story one: the pen-test replay. Our tester stood up a malicious server advertising our AS, phished one engineer through a fake tool-install page, and replayed the token against our repo server. Green 200. Root cause was resource-agnostic issuance. After binding tokens to aud = {base}/mcp and forwarding resource, the same replay returns 401. We now run this replay as a weekly CI check with two local proxies.
War story two: the corporate proxy outage. Our JWKS fetches started failing after a network change forced all egress through a mandated proxy. FastMCP refused the fetch rather than bypassing the proxy — correct behavior, terrible 6 AM surprise. Fix was 3.4.6 trusted-proxy config: route metadata and JWKS through the configured proxy with our custom CA, and alert when no proxy is set instead of failing silently. Document your egress path before you need it.
The approval-layer companion is governed review graphs: bound tokens at the tool layer, HUMAN gates at the workflow layer.
When NOT to use the proxy
Skip it for pure local STDIO servers. No OAuth exists there, and the proxy adds nothing but config. Ship without auth and document the boundary.
Skip it when your provider natively supports MCP auth with token verification. Then verify directly with Auth0MCPProvider or equivalent and avoid the extra hop. Fewer moving parts, lower latency.
Skip the consent screen only in trusted dev. Disabling it removes confused-deputy protection. We allow consent=False on localhost, never in staging or production.
Use the proxy when clients must discover and register on their own against providers without DCR. That is GitHub, Google, and most enterprise IdPs today.
Ship checklist
- Set a persistent
jwt_signing_keyper environment. Ephemeral keys log everyone out on restart. - Enforce S256-only PKCE. Refuse
plaineverywhere. - Forward
resourceand validateaudper server. One audience per MCP mount. - Advertise and enforce
iss. Keep clients current so mix-up validation runs. - Lock metadata/JWKS egress behind SSRF guards and your corporate proxy config.
Bottom line: the proxy is the front door. Bind every token, check every issuer, guard every fetch. Attackers test all three.
By Deepak Bagada, Founder & Editor-in-Chief at Daily AI World.
Enjoyed this breakdown? Get our morning dispatch in your inbox.
Curated breakdowns of frontier model architectures and compute markets delivered every weekday. Zero fluff.
Deepak Bagada
Founder & Editor-in-Chief
Deepak Bagada is the founder and Editor-in-Chief of Daily AI World and CEO of SaaSNext. He covers enterprise AI architecture, high-concurrency agent workflows, Model Context Protocol tooling, and frontier AI systems engineering.
Factory Triples to $5B: $200M Bet on Autonomous Droids
Next Story →Anthropic Opens Transcripts to METR as OpenAI Urges Law
Related Intelligence Analysis
Vercel AI SDK Tool Calling React: 5 Steps (2026)
Vercel AI SDK tool calling React integration is a programming pattern that executes server-side functions based on large language model decisions and streams the results to a React frontend. By combining streamText with...
Fact-Density vs. Word Count: The New SEO for 2026
Fact Density is the ratio of verifiable, unique information to the total word count of a piece of content. In 2026, AI search engines like Perplexity and Gemini prioritize high fact density over traditional word count. A...
NVIDIA Audex vs Qwen3.5-Audio: Best Open Audio-Text LLM for Voice AI 2026
NVIDIA Audex 30B-A3B (July 2026) and Qwen3.5-35B-A3B are the two leading open audio-text LLMs. Audex uniquely handles both audio understanding and generation in a single model while preserving text intelligence. Qwen3.5-...