🔐 Spoke 5 of 10 · MCP Server Ops

MCP Server Authentication: API Keys, JWT, and OAuth 2.1

Stop shipping open endpoints. Add API key middleware, graduate to JWTVerifier, gate destructive tools with per-scope rules, and lock down CORS — all before your first real user.

REQUIRE_AUTH=false Never in Production
15 lines Min Viable Auth
60/min Default Rate Limit
8 Common Mistakes

Threat Model: What Happens With REQUIRE_AUTH=false in Production

An unprotected HTTP MCP server exposes every registered tool to any caller who can reach the endpoint. Vercel functions are publicly accessible by default. An open server can appear in MCP registries like glama.ai within 24 hours of deployment.

ThreatMechanismExample Impact
Unauthorized tool execution Direct POST to /mcp initiate_checkout runs arbitrary Stripe sessions charged to your account
Cost overruns Unbounded tool calls Thousands of DB queries or LLM calls billed to your account
Data exfiltration Read-only tools return sensitive data search_products leaks pricing and inventory to competitors
Prompt injection amplification Attacker plants malicious instructions via public tool results Model executes destructive operations on attacker's behalf
Downstream credential abuse Your server holds API keys for other services Those keys leak through tool call responses or logs

When REQUIRE_AUTH=false is acceptable

  • Local development on localhost with no public URL or tunnel
  • STDIO transport — the MCP spec explicitly says STDIO servers SHOULD NOT implement OAuth; they get credentials from the environment
  • A demo environment with no real data and no real payment keys
REQUIRE_AUTH=false is not appropriate for any server reachable by a public IP, a tunnel (ngrok, Cloudflare Tunnel), or a cloud URL — even "internal" servers that colleagues connect to remotely.

Toggle auth by environment (safe default pattern)

server.py — fail safe, default to requiring auth
import os, sys from fastmcp import FastMCP # Default to true — REQUIRE_AUTH=false only in local .env, never in Vercel require_auth = os.environ.get("REQUIRE_AUTH", "true").lower() != "false" # Crash at startup if production is misconfigured if os.environ.get("ENVIRONMENT") == "production" and not require_auth: print("FATAL: REQUIRE_AUTH=false in production", file=sys.stderr) sys.exit(1)

API Key Middleware: FlexAuthMiddleware

The simplest production-viable auth for a server you control completely. 15 lines of middleware that run before every tool call — no database, no IdP, no infrastructure.

FastMCP 3.3.1 has no dedicated ApiKeyVerifier class; use the Middleware base class. The implementation below accepts both X-API-Key header and Authorization: Bearer — supporting mcp-remote and direct API clients from one class:

server.py — FlexAuthMiddleware (accepts both header styles)
import os from fastmcp import FastMCP from fastmcp.server.middleware import Middleware, MiddlewareContext from fastmcp.server.dependencies import get_http_headers from fastmcp.exceptions import ToolError # Set in Vercel → Settings → Environment Variables # VALID_API_KEYS=sk-prod-abc123,sk-prod-def456 VALID_KEYS: set[str] = set( k.strip() for k in os.environ.get("VALID_API_KEYS", "").split(",") if k.strip() ) class FlexAuthMiddleware(Middleware): """Accepts X-API-Key header or Authorization: Bearer <key>.""" async def on_call_tool(self, context: MiddlewareContext, call_next): headers = get_http_headers() # header names are lowercased token = self._extract_token(headers) if not token or token not in VALID_KEYS: raise ToolError("Unauthorized: invalid or missing credentials") # Log suffix for attribution without exposing full key context.fastmcp_context.set_state("key_suffix", token[-6:]) return await call_next(context) @staticmethod def _extract_token(headers: dict) -> str | None: if key := headers.get("x-api-key"): # X-API-Key takes precedence return key auth = headers.get("authorization", "") if auth.startswith("Bearer "): return auth.removeprefix("Bearer ").strip() return None mcp = FastMCP(name="Product API") mcp.add_middleware(FlexAuthMiddleware()) @mcp.tool def search_products(query: str) -> list[dict]: return [{"id": "prod-1", "name": "Widget", "price": 29.99}] mcp.run(transport="streamable-http", host="0.0.0.0", port=8000, path="/mcp")

One key per client (stronger pattern)

A single shared key means one leak invalidates all clients. Store a JSON map for per-client identity and individual revocation:

Vercel env var: VALID_API_KEYS
# Comma-separated (simple) VALID_API_KEYS=sk-prod-abc123,sk-prod-def456,sk-prod-ghi789 # JSON map (per-client identity + logging) VALID_API_KEYS={"claude-desktop":"sk-prod-abc123","ci-bot":"sk-prod-def456"}
Key rotation with zero downtime: Add the new key to VALID_API_KEYS first, redeploy, then distribute the new key to clients, then remove the old key and redeploy again. Both keys work during the transition window.

Bearer Token: Wiring mcp-remote to Your Server

mcp-remote is the Node.js bridge Claude Desktop uses for HTTP MCP transports. It passes custom headers via the --header flag on every request — SSE connection and all tool calls.

Pin to mcp-remote@0.1.38. Versions 0.0.5–0.1.15 have an active security vulnerability (CVE-2025-6514). Never use an unpinned npx mcp-remote.
Option A

X-API-Key header

~/.config/Claude/claude_desktop_config.json
{ "mcpServers": { "my-server": { "command": "npx", "args": [ "-y", "mcp-remote@0.1.38", "https://my-app.vercel.app/mcp", "--header", "X-API-Key:${MY_API_KEY}" ], "env": { "MY_API_KEY": "sk-prod-abc123" } } } }
Option B

Authorization: Bearer header

~/.config/Claude/claude_desktop_config.json
{ "mcpServers": { "my-server": { "command": "npx", "args": [ "-y", "mcp-remote@0.1.38", "https://my-app.vercel.app/mcp", "--header", "Authorization:${AUTH_HEADER}" ], "env": { "AUTH_HEADER": "Bearer sk-prod-abc123" } } } }

Note: the space is inside the env var value ("Bearer sk-..."), not around the colon in the arg. This avoids the Windows/Cursor npx argument-splitting bug.

FastMCP Python client equivalent: StreamableHttpTransport("https://your-server.vercel.app/mcp", auth="sk-prod-abc123") — FastMCP wraps this as Authorization: Bearer automatically.

JWT Verification with FastMCP's JWTVerifier

JWTVerifier (FastMCP 3.3.1, from fastmcp.server.auth.providers.jwt import JWTVerifier) validates cryptographically signed tokens without a database lookup. It checks signature, expiration (exp), issuer (iss), and audience (aud).

Use JWTVerifier when: credentials need to expire, carry per-user scopes, be issued by an identity provider, or when you have multiple service identities needing distinct credentials.

Option A: RS256 asymmetric keys (production)

Generate keys once on your development machine. The server only needs the public key — the private key stays on your machine and never touches Vercel:

bash — generate RSA keypair
openssl genpkey -algorithm RSA -out private.pem -pkeyopt rsa_keygen_bits:2048 openssl rsa -in private.pem -pubout -out public.pem # Base64-encode for Vercel env var (multiline PEM doesn't serialize cleanly) base64 -i public.pem | tr -d '\n' # Copy output → JWT_PUBLIC_KEY_B64 in Vercel Settings → Environment Variables
server.py — RS256 JWTVerifier
import os, base64 from fastmcp import FastMCP from fastmcp.server.auth.providers.jwt import JWTVerifier # Decode base64-encoded public key from env public_key_pem = base64.b64decode(os.environ["JWT_PUBLIC_KEY_B64"]).decode() verifier = JWTVerifier( public_key=public_key_pem, issuer=os.environ["JWT_ISSUER"], # e.g. "https://auth.your-domain.com" audience=os.environ["JWT_AUDIENCE"], # e.g. "my-mcp-server" # algorithm defaults to RS256 for PEM keys ) mcp = FastMCP(name="Product API", auth=verifier) @mcp.tool def search_products(query: str) -> list[dict]: return [{"id": "prod-1", "name": "Widget", "price": 29.99}] mcp.run(transport="streamable-http", host="0.0.0.0", port=8000, path="/mcp")
issue_token.py — run locally to generate client tokens
from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair from pydantic import SecretStr with open("private.pem") as f: private_key_pem = f.read() with open("public.pem") as f: public_key_pem = f.read() key_pair = RSAKeyPair( private_key=SecretStr(private_key_pem), public_key=public_key_pem, ) token = key_pair.create_token( subject="developer@your-domain.com", issuer="https://auth.your-domain.com", audience="my-mcp-server", scopes=["read", "write"], # see per-tool scopes section expires_in_seconds=86400 * 90, # 90 days for a personal tool ) print(token) # Set in mcp-remote env: AUTH_HEADER="Bearer <token>"

Option B: HS256 symmetric key (internal tools, simpler)

bash — generate 256-bit secret
python -c "import secrets; print(secrets.token_hex(32))" # Store 64-char output as JWT_SECRET in Vercel env vars
server.py — HS256 JWTVerifier
verifier = JWTVerifier( public_key=os.environ["JWT_SECRET"], # minimum 32 chars issuer="my-mcp-server", audience="my-mcp-server", algorithm="HS256", ) mcp = FastMCP(name="Internal API", auth=verifier)

Option C: JWKS endpoint (Auth0, Azure AD, Keycloak)

server.py — external IdP via JWKS
verifier = JWTVerifier( jwks_uri=os.environ["JWKS_URI"], # Auth0: https://your-tenant.auth0.com/.well-known/jwks.json # Azure: https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys issuer=os.environ["JWT_ISSUER"], audience=os.environ["JWT_AUDIENCE"], ) mcp = FastMCP(name="Enterprise API", auth=verifier)

OAuth 2.1: What the MCP Spec Actually Requires

The MCP authorization specification (2025-11-25) is unambiguous:

"Authorization is OPTIONAL for MCP implementations. Implementations using a STDIO transport SHOULD NOT follow this specification, and instead retrieve credentials from the environment."

In plain terms: OAuth 2.1 is not required for any MCP server. For Claude Desktop + mcp-remote (STDIO transport), the spec explicitly says to use environment credentials — meaning static Bearer tokens via --header in mcp-remote config is the correct, spec-aligned approach.

Recommended auth by deployment scenario

Use CaseRecommended AuthComplexity
Personal tool, your Claude Desktop onlyStatic Bearer token via mcp-remote --headerLow
Small team (2–10 developers you control)JWTVerifier + RSAKeyPair-issued long-lived tokensMedium
Enterprise (Auth0, Azure AD, Okta users)JWTVerifier(jwks_uri=...) against your existing IdPMedium
Public server (any OAuth 2.1 client can connect)Full OAuth 2.1 via RemoteAuthProvider + external IdPHigh

If you do implement OAuth 2.1 (public servers)

The 2025-11-25 spec mandates for HTTP-transport servers that choose to implement OAuth:

  • OAuth 2.1 with PKCE (S256 code challenge — clients must refuse if the AS doesn't advertise S256)
  • RFC 9728 Protected Resource Metadata at /.well-known/oauth-protected-resource
  • RFC 8414 Authorization Server Metadata at /.well-known/oauth-authorization-server
  • RFC 8707 Resource Indicators — resource parameter in every token request
  • Bearer token in Authorization header only — never in URI query string
  • Token audience must match your server URI (no token passthrough to downstream APIs)
Dynamic Client Registration (DCR) is now downgraded to MAY. The primary mechanism in the 2025-11-25 spec is Client ID Metadata Documents (CIMD) — the client's client_id is an HTTPS URL; the authorization server fetches that URL to get the client's redirect URIs without pre-registration. DCR remains for backwards compatibility only.

CORS Lockdown: From * to Specific Origins

CORS applies only to browser-based clients. Claude Desktop and mcp-remote are server-side Node.js processes — they do not send Origin headers and are not affected by your CORS configuration at all.

allow_origins=["*"] combined with allow_credentials=True is prohibited by the browser spec. If your FastAPI app uses credentials, the wildcard silently breaks browser-based clients. Always use an explicit origin list in production.
server.py — production CORS configuration
from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware app = FastAPI() ALLOWED_ORIGINS = [ "https://claude.ai", # Claude.ai web interface "https://app.claude.ai", # Claude.ai alternate subdomain "http://localhost:5173", # MCP Inspector (development) "http://localhost:3000", # Local web development frontend # Add your production web frontend URL here ] app.add_middleware( CORSMiddleware, allow_origins=ALLOWED_ORIGINS, allow_credentials=True, allow_methods=["GET", "POST", "DELETE", "OPTIONS"], allow_headers=[ "Content-Type", "Authorization", "Mcp-Session-Id", # Required for Streamable HTTP session continuity "Accept", "X-API-Key", ], expose_headers=[ "Mcp-Session-Id", # Browser JS must read this — blocked without expose "Retry-After", "X-RateLimit-Limit", "X-RateLimit-Remaining", ], )
ClientSends CORS Preflight?Add to allow_origins?
Claude Desktop (Electron app)No — Electron bypasses CORSNothing needed
mcp-remote (CLI bridge)No — Node.js processNothing needed
Browser web clientYesAdd your frontend domain(s)
MCP Inspector (browser)Yeshttp://localhost:5173
Without expose_headers=["Mcp-Session-Id"], browsers receive the session ID in the response but JavaScript cannot read it (CORS blocks non-safelisted headers). Stateful MCP sessions break silently for web clients.

Per-Tool Authorization: require_scopes

Once JWT auth is configured, FastMCP's require_scopes decorator gates individual tools based on the JWT scope claim. FastMCP hides unauthorized tools from tools/list automatically — the LLM never sees tools the caller cannot use.

server.py — scope-gated tools
import os from fastmcp import FastMCP from fastmcp.server.auth import require_scopes from fastmcp.server.auth.providers.jwt import JWTVerifier verifier = JWTVerifier( public_key=os.environ["JWT_PUBLIC_KEY"], issuer=os.environ["JWT_ISSUER"], audience=os.environ["JWT_AUDIENCE"], ) mcp = FastMCP(name="Product API", auth=verifier) # Any valid JWT grants access — read-only, no scope annotation @mcp.tool def search_products(query: str) -> list[dict]: """Search product catalog. Any authenticated caller.""" return [{"id": "prod-1", "name": "Widget", "price": 29.99}] # Requires "write" scope — 403 Forbidden if JWT only has ["read"] @mcp.tool(auth=require_scopes("write")) def initiate_checkout(product_id: str, quantity: int) -> dict: """Place an order. Requires write scope. FastMCP enforces before body runs.""" return {"session_url": "https://checkout.stripe.com/..."} # Requires both "write" AND "admin" scopes (AND logic) @mcp.tool(auth=require_scopes("write", "admin")) def delete_product(product_id: str) -> dict: """Delete a product. Requires write AND admin scopes.""" return {"deleted": True} mcp.run(transport="streamable-http", host="0.0.0.0", port=8000, path="/mcp")

Tag-based auth for servers with many tools

server.py — tag-based middleware (cleaner for large servers)
from fastmcp.server.auth import restrict_tag from fastmcp.server.middleware import AuthMiddleware mcp = FastMCP( name="Product API", auth=verifier, middleware=[ AuthMiddleware(auth=restrict_tag("write", scopes=["write"])), AuthMiddleware(auth=restrict_tag("admin", scopes=["admin"])), ] ) @mcp.tool(tags={"write"}) def initiate_checkout(product_id: str, quantity: int) -> dict: return {} # requires "write" scope — enforced by tag middleware @mcp.tool def search_products(query: str) -> list[dict]: return [] # no scope required beyond authentication
Token classes to issue: Read-only monitoring bots → scopes=["read"]. Claude Desktop with full access → scopes=["read", "write"]. Admin service account → scopes=["read", "write", "admin"]. A read-only token calling initiate_checkout gets 403 Forbidden — FastMCP intercepts before your function body runs.

Rate Limiting with slowapi

slowapi (v0.1.9) wraps flask-limiter patterns for Starlette/FastAPI. A 60 req/min per-IP default with in-memory storage is enough to prevent abuse for most solo-operator servers.

bash — install
pip install slowapi==0.1.9 fastapi uvicorn
server.py — complete rate limiting setup
import math, os, time from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from slowapi import Limiter from slowapi.util import get_remote_address from slowapi.errors import RateLimitExceeded from slowapi.middleware import SlowAPIMiddleware from fastmcp import FastMCP # Add CORSMiddleware BEFORE SlowAPIMiddleware so OPTIONS preflights # don't count against the rate limit. limiter = Limiter( key_func=get_remote_address, default_limits=["60/minute"], headers_enabled=True, # adds X-RateLimit-* headers to all 2xx responses ) app = FastAPI() app.state.limiter = limiter app.add_middleware(SlowAPIMiddleware) @app.exception_handler(RateLimitExceeded) async def rate_limit_handler(request: Request, exc: RateLimitExceeded): retry_after = max(math.ceil(exc.limit.reset_time - time.time()), 1) return JSONResponse( status_code=429, headers={ "Retry-After": str(retry_after), "X-RateLimit-Limit": str(exc.limit.limit), "X-RateLimit-Remaining": "0", }, content={ "error": "rate_limit_exceeded", "detail": f"Limit: {exc.limit.limit}. Retry after {retry_after}s.", "retry_after": retry_after, }, ) mcp = FastMCP(name="Product API", auth=verifier) app.mount("/mcp", mcp.get_asgi_app())
In-memory limits are per function instance on Vercel. A client spread across 10 cold starts gets 10× the stated limit. For accurate cross-instance rate limiting, use a Redis backend: pip install "slowapi[redis]==0.1.9" and pass storage_uri=os.environ["REDIS_URL"] to Limiter(). Upstash Redis works with Vercel serverless and has a free tier.

Auth Method Comparison

Low Complexity

X-API-Key / Bearer token via mcp-remote --header

Best for personal tools and internal servers. 15 lines of middleware, no infrastructure. Static secrets don't expire — add per-client keys and rotate periodically. Correct spec-aligned approach for Claude Desktop (STDIO transport).

Medium Complexity

JWTVerifier (RS256 / HS256) — self-issued tokens

Best for small teams and multi-client deployments. Tokens expire automatically, carry scopes for per-tool gating, and never need a database lookup. Generate keys with openssl, issue tokens with RSAKeyPair.create_token(), store base64 public key in Vercel.

Medium Complexity

JWTVerifier + external IdP (Auth0, Azure AD, Keycloak)

Best for teams with existing SSO. Point JWTVerifier(jwks_uri=...) at your IdP's JWKS endpoint — FastMCP handles key rotation automatically. Users authenticate through your existing IdP flow.

High Complexity

Full OAuth 2.1 (RemoteAuthProvider + CIMD)

Best for public servers where any OAuth 2.1 client should be able to self-configure. Requires building or running an authorization server, implementing 5+ discovery endpoints, handling PKCE flows, and managing CIMD hosting. Only warranted if external users you don't control will connect arbitrary MCP clients.

MethodMCP Spec Required?Tokens Expire?Per-Tool Scopes?
No auth (REQUIRE_AUTH=false)N/A — localhost onlyN/ANo
X-API-Key / Bearer static keyNoNoNo
JWTVerifier HS256NoYesYes (with require_scopes)
JWTVerifier RS256 / JWKSNo (satisfies HTTP transport intent)YesYes
Full OAuth 2.1SHOULD if HTTP + auth enabledYesYes

8 Common Mistakes That Break MCP Auth

1REQUIRE_AUTH=false copied to Vercel from local .env

Easy to paste a dev env file into Vercel without noticing this flag. Add a startup assertion that crashes the process in production rather than running unsecured:

import sys, os if os.environ.get("ENVIRONMENT") == "production" and os.environ.get("REQUIRE_AUTH") == "false": print("FATAL: REQUIRE_AUTH=false in production", file=sys.stderr) sys.exit(1)

2API key hardcoded in claude_desktop_config.json args

The config file is stored on disk and may be committed to version control. Keys in git history are permanent even after deletion.

// WRONG — key is in version control "args": [..., "--header", "Authorization: Bearer sk_hardcoded_key"] // CORRECT — key comes from environment "args": [..., "--header", "Authorization:${AUTH_HEADER}"], "env": { "AUTH_HEADER": "Bearer sk-prod-abc123" }

3Middleware only checks X-API-Key, not Authorization: Bearer

mcp-remote and FastMCP Python clients send Authorization: Bearer, not X-API-Key. Middleware that only checks X-API-Key silently rejects these clients with 401. Use FlexAuthMiddleware from Section 2 that checks both headers.

4Using mcp-remote 0.0.5–0.1.15 (CVE-2025-6514)

These versions have an active security vulnerability. Always pin to the patched version:

npx -y mcp-remote@0.1.38 https://your-server.com/mcp

5Short or guessable HS256 secret

HS256 secrets can be brute-forced if under 32 bytes. A leaked secret compromises every token ever issued with it. Generate 256 bits of entropy:

python -c "import secrets; print(secrets.token_hex(32))" # 64 hex chars (256 bits). Store as JWT_SECRET in Vercel env vars.

6Spaces around the colon in mcp-remote --header args

"Authorization: Bearer token" as a single arg string gets split by npx on Windows and in Cursor, producing a mangled header. Put the space inside the env var value:

// WRONG — space in arg string breaks on Windows/Cursor "--header", "Authorization: Bearer your-token" // CORRECT — space inside env var value "--header", "Authorization:${AUTH_HEADER}", "env": { "AUTH_HEADER": "Bearer your-token" }

7allow_origins=["*"] with allow_credentials=True

The browser spec prohibits this combination. Auth headers silently fail for browser-based clients — no error, just CORS rejection. Replace the wildcard with an explicit list of your actual frontend domains.

8No per-tool scope differentiation — all tools share one scope

Granting all tools the same scope means any authenticated client can call initiate_checkout even if it should only have read access. Define distinct scopes and assign per tool:

@mcp.tool(auth=require_scopes("write")) def initiate_checkout(...): ... # requires write scope @mcp.tool def search_products(...): ... # any authenticated caller

Add Production Auth in 5 Steps

  1. 1
    Add FlexAuthMiddleware to your FastMCP server

    Implement FlexAuthMiddleware from Section 2. Set VALID_API_KEYS=sk-prod-abc123 (comma-separated for multiple clients) in Vercel → Settings → Environment Variables. Set REQUIRE_AUTH=true (or omit it and default to true in code). Deploy. Verify that requests without a key receive 401.

  2. 2
    Update claude_desktop_config.json to pass the key

    Add "--header", "X-API-Key:${MY_API_KEY}" to the mcp-remote args and "MY_API_KEY": "sk-prod-abc123" to the "env" block. Pin to mcp-remote@0.1.38. Fully quit and relaunch Claude Desktop (menu bar quit, not just close window) to load the new config. Verify the tools menu shows your server's tools.

  3. 3
    Graduate to JWTVerifier for expiring credentials

    Generate an RSA keypair with openssl. Configure JWTVerifier(public_key=..., issuer=..., audience=...) on your FastMCP server. Run issue_token.py locally using RSAKeyPair.create_token(). Update mcp-remote config to pass "Authorization:${AUTH_HEADER}" with "AUTH_HEADER": "Bearer <jwt>". Store the base64-encoded public key in Vercel as JWT_PUBLIC_KEY_B64. Test by verifying the server rejects expired or wrong-audience tokens.

  4. 4
    Add per-tool scope gates for destructive tools

    Add auth=require_scopes("write") to @mcp.tool on initiate_checkout and any tool that writes data or triggers payments. Issue read-only tokens with scopes=["read"] for monitoring bots and scopes=["read", "write"] for Claude Desktop. Test by calling initiate_checkout with a read-only token — expect 403 Forbidden before your function body executes.

  5. 5
    Lock down CORS and add rate limiting

    Install slowapi==0.1.9. Add SlowAPIMiddleware with default_limits=["60/minute"] and a custom 429 handler that returns a Retry-After header. Replace allow_origins=["*"] with an explicit list. Add CORSMiddleware before SlowAPIMiddleware so OPTIONS preflights don't count against the rate limit. Verify: no token → 401 with WWW-Authenticate: Bearer; 61st request → 429 with Retry-After.

Frequently Asked Questions

  • Is REQUIRE_AUTH=false safe on a public Vercel deployment?
    No. Vercel functions are publicly accessible by default. Without auth, any MCP client that discovers your endpoint can call all your tools. An open server can appear in MCP registries like glama.ai within 24 hours of deployment. If initiate_checkout connects to a real Stripe account, unauthorized callers can create payment sessions charged to you. The minimum viable protection is 15 lines: FlexAuthMiddleware plus a single API key in a Vercel env var — no infrastructure required.
  • Does the MCP spec require OAuth 2.1?
    No. The 2025-11-25 spec says authorization is OPTIONAL. When an HTTP server implements it, the implementation SHOULD conform — but the spec is silent on servers that choose not to implement authorization at all. More relevantly, the spec explicitly says STDIO transport implementations SHOULD NOT implement OAuth and should use environment credentials instead. Claude Desktop uses STDIO via mcp-remote, so static Bearer tokens via --header are the spec-aligned approach for most developers.
  • What is JWTVerifier and when should I use it instead of API key middleware?
    JWTVerifier validates cryptographically signed tokens — it checks RSA or HMAC signature, verifies exp (expiration), iss (issuer), and aud (audience). FlexAuthMiddleware just does a set-membership check: is this string in my valid keys? Use JWTVerifier when you need tokens to expire, carry per-user scopes for require_scopes, be issued by an identity provider, or when you have multiple service identities needing distinct credentials. Use FlexAuthMiddleware when you just need a shared secret with no expiry complexity.
  • How does mcp-remote pass auth headers to my server?
    Use the --header flag with KEY:VALUE format (no space around the colon). Store secrets in env vars using ${VAR_NAME} substitution. In claude_desktop_config.json: "args": ["-y", "mcp-remote@0.1.38", "https://server.vercel.app/mcp", "--header", "Authorization:${AUTH_HEADER}"] with "env": {"AUTH_HEADER": "Bearer sk-prod-abc123"}. The space goes inside the env var value, not the arg string. mcp-remote forwards this on every HTTP request including SSE connections and all tool calls.
  • How do I gate initiate_checkout so only privileged clients can call it?
    Add @mcp.tool(auth=require_scopes("write")) from fastmcp.server.auth. Issue read-only clients tokens with scopes=["read"] and Claude Desktop tokens with scopes=["read", "write"]. When a read-only JWT calls initiate_checkout, FastMCP intercepts the call before your function body runs and returns 403 Forbidden. FastMCP also hides unauthorized tools from tools/list automatically — the LLM never proposes calling tools the client cannot use.
  • Why doesn't CORS affect Claude Desktop connectivity?
    Claude Desktop uses mcp-remote, a Node.js process — not a browser. CORS is enforced by browsers only. mcp-remote does not send an Origin header, so your CORS middleware is irrelevant to Claude Desktop traffic. If Claude Desktop can't connect, the issue is elsewhere: check that the mcp-remote --header arg matches what your auth middleware expects, verify the server URL returns 200 at /mcp, and check Vercel function logs for auth errors or 404s on the path.
  • Does in-memory slowapi rate limiting work accurately on Vercel?
    No. In-memory limits reset per function instance — a client spread across 10 Vercel cold starts effectively gets 10× the stated limit. For accurate cross-instance rate limiting, configure a Redis backend: pip install "slowapi[redis]==0.1.9" and pass storage_uri=os.environ["REDIS_URL"] to Limiter(). Upstash Redis works with Vercel serverless and has a free tier — re-verify current limits before launch.
  • What mcp-remote version should I use and why does it matter?
    Pin to mcp-remote@0.1.38. Versions 0.0.5–0.1.15 have an active security vulnerability (CVE-2025-6514). Always specify the version explicitly in your args: npx -y mcp-remote@0.1.38. Never use an unpinned npx mcp-remote which could resolve to a vulnerable version on next install.