Skip to main content
Subscribe
Front Page / AI Tools / Deep Dive

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

Deepak Bagada

Founder & Editor-in-Chief

Sep 18, 2026 Published
|
Sep 18, 2026 Updated
|
7 Minutes Reading Time
Core Takeaways for Founders & Builders
  • 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 iss validation 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

  1. Set a persistent jwt_signing_key per environment. Ephemeral keys log everyone out on restart.
  2. Enforce S256-only PKCE. Refuse plain everywhere.
  3. Forward resource and validate aud per server. One audience per MCP mount.
  4. Advertise and enforce iss. Keep clients current so mix-up validation runs.
  5. 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 , Founder & Editor-in-Chief at Daily AI World.

Executive Briefing

Enjoyed this breakdown? Get our morning dispatch in your inbox.

Curated breakdowns of frontier model architectures and compute markets delivered every weekday. Zero fluff.

🎉 Thank You for Subscribing!

Frequently Asked Questions
Pre-2.14.2 proxies issued tokens for the proxy base URL instead of the requested resource. A malicious server could advertise your authorization server, complete one victim flow, and replay the token against your benign server. Bind tokens to aud per mount and forward RFC 8707 resource indicators.
RFC 9207 puts the issuer identifier in every authorization response and advertises support in metadata. Clients compare it against the expected issuer to detect mix-up attacks where a malicious server substitutes its own authorization endpoint.
Set a persistent jwt_signing_key, enforce S256-only PKCE, forward resource parameters, validate aud per server, keep iss enforcement on, and route metadata and JWKS fetches through SSRF guards plus your corporate proxy.
About 16ms over an unpatched proxy: 22ms to 38ms p50 in our tests. Refresh got 47% faster from per-token cache partitioning, and session capacity rose 6x.
Deepak Bagada
Author Profile

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.

Related Intelligence Analysis

Briefing AI Tools

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...

Deepak Bagada Deepak Bagada
12m read
Breaking AI Tools

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...

Deepak Bagada Deepak Bagada
4m read
Audio Briefing
Accessibility Preferences
High Contrast Mode
Accessible Reading Font

Keyboard Shortcuts

Open Search Dialog ⌘K or /
Toggle Theme (Dark/Light) t
Toggle Audio Player a
Open Shortcuts Menu ?
Close Active Dialog Esc

Cookie & Privacy Preferences

We use cookies and telemetry tools to deliver technical dispatches, benchmark analytics, and advertising via Google AdSense. Review our Privacy Policy.