Building a Claude connector authenticated by your own OpenID Connect (OAuth2) provider

This is a walkthrough of building a Claude custom connector (a remote MCP server) and authenticating it against your own OpenID Connect provider.
What we build is a small MCP server that does nothing but return the login name of the authenticated user. Claude.ai, Claude Desktop, mobile and Cowork all share the same connector platform, so registering it once makes it available everywhere.
Here is the stack used in this article:
| Role | What we use |
|---|---|
| MCP server | Python + FastMCP 3.x (Streamable HTTP / uvicorn) |
| OIDC provider (authorization server) | Django + Django OAuth Toolkit 3.x |
| Infrastructure | Kubernetes (exposed through ingress-nginx) |
That said, what Claude requires does not depend on your implementation or environment. The protected resource metadata of RFC 9728, discovery at the issuer root, PKCE, audience validation — all of it applies just as well in another language, with another OIDC provider, on other infrastructure. Where something is library-specific, I say so.
What is a Claude custom connector
A custom connector is just an MCP server. There is no dedicated connector format or framework. You publish an MCP server over Streamable HTTP and register its URL in Claude's settings. There is no such thing as a "Cowork-only connector" either.
What matters is that the OAuth client side is already built into Claude. Adding authentication does not mean writing a login screen or a callback endpoint.
What Claude handles for you:
- Discovery of the protected resource metadata and the authorization server metadata
- The client half of PKCE (generating the
code_verifier, deriving thecode_challenge, sending the verifier with the token request) - Running the authorization code flow (opening the browser, showing the consent screen, exchanging the code for a token)
- Hosting the redirect URI (
https://claude.ai/api/mcp/auth_callback) - Storing and refreshing tokens
- Attaching
Authorization: Bearerto subsequent requests
Verifying PKCE is the authorization server's job. Claude only generates and sends the verifier and challenge; storing the challenge and comparing it against the verifier happens on the authorization server (RFC 7636 §4.6). "Claude takes care of it, so I don't have to" is the wrong reading. Confirm that your authorization server advertises S256 in code_challenge_methods_supported and actually verifies it.
You only write three things on the resource server side:
- Return 401 when unauthenticated, and point at the entrance with
WWW-Authenticate - Serve the protected resource metadata (which authorization server to use, which scopes are needed)
- Verify the bearer token you receive
You can hand Claude a client_secret during registration because Claude is a confidential client. Under RFC 6749 §2.1, a client that can keep its credentials confidential is confidential; one that cannot is public. Unlike an in-browser SPA or a native app, Claude keeps the secret on its servers, so it falls into the former category.
Terminology
| OAuth 2.0 / OIDC role | Who plays it here |
|---|---|
| Authorization Server / OpenID Provider | Your own OIDC provider |
| Client / Relying Party | Claude |
| Resource Server | The MCP server we are building |
| Resource Owner | The user of the connector |
Incidentally, some people call this client a Consumer, but OAuth 2.0 has no such role. "Client" is the correct term. Consumer / Service Provider is OAuth 1.0 Core vocabulary; RFC 5849 already renamed them to client / server, and OAuth 2.0 (RFC 6749) says client throughout. In an OIDC context, Relying Party (RP) is the more precise name.
The overall flow
Claude ──(1) POST /mcp (no token)──────────────────> MCP server
<──(2) 401 + WWW-Authenticate: Bearer resource_metadata="…" ──
──(3) GET /.well-known/oauth-protected-resource/mcp ────────>
<──(4) { resource, authorization_servers, scopes_supported } ─
──(5) GET https://id.example.com/.well-known/openid-configuration ──> OIDC provider
──(6) Authorization code flow (PKCE S256) + user consent ───>
──(7) POST /mcp (Bearer token) ────────────────> MCP server
└─(8) POST /o/introspect/ ──> OIDC provider
Claude works in this order: get a 401, read the metadata URL it names, then use the authorization server that metadata names. If any single link in the 401 → metadata → discovery chain is broken, you never even reach the consent screen.
What Claude requires
| Item | Requirement |
|---|---|
| Transport | Remote MCP (Streamable HTTP) |
| Unauthenticated response | 401 + WWW-Authenticate: Bearer resource_metadata="…". Attaching it to a 200 is ignored |
| Resource metadata | RFC 9728. resource must exactly match the URL the user types into Claude, path included |
| Authorization server metadata | RFC 8414 or OIDC Discovery, served directly under the issuer's /.well-known/ |
| PKCE | Required. code_challenge_method=S256 |
| Redirect URI | https://claude.ai/api/mcp/auth_callback |
| Client registration | DCR (Dynamic Client Registration, RFC 7591) / CIMD (Client ID Metadata Document) / for a custom connector, client_id + secret entered by hand |
| Grant | Authorization code flow with user consent. client_credentials is not supported |
| Timeouts | 10 seconds for discovery / registration / token, 30 seconds for refresh |
| Source IP | 160.79.104.0/21 (Anthropic's outbound range) |
Sources: Building connectors — Authentication and IP addresses. The redirect URI, timeouts and IP ranges can change, so check the originals when you implement.
Building the MCP server
The code below consists of excerpts. Imports and configuration loading are omitted, so it will not run as-is. It shows the structure and the parts that matter.
In Python, FastMCP 3.x already carries most of the MCP and OAuth wiring.
uv add "fastmcp==3.4.5" "uvicorn[standard]"
Pin the version. The auth API changed between FastMCP 2.x and 3.x, and a lot of what you find online still assumes 2.x.
Assembling the auth provider
Give RemoteAuthProvider something that verifies tokens and the URL of your authorization server, and it will grow the RFC 9728 metadata endpoint and the 401 response for you.
Two notes up front; both are explained in detail later.
- Token verification uses introspection, not UserInfo (see "Verify tokens with introspection")
- The client_id / client_secret passed here belong to a different Application than the one you enter into Claude (see "Use two sets of credentials")
from fastmcp import FastMCP
from fastmcp.server.auth import RemoteAuthProvider
from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier
verifier = IntrospectionTokenVerifier(
introspection_url="https://id.example.com/o/introspect/",
client_id=RESOURCE_SERVER_CLIENT_ID,
client_secret=RESOURCE_SERVER_CLIENT_SECRET,
client_auth_method="client_secret_basic",
required_scopes=["openid"],
cache_ttl_seconds=60,
)
auth = RemoteAuthProvider(
token_verifier=verifier,
authorization_servers=["https://id.example.com"],
base_url="https://mcp.example.com", # no path here
scopes_supported=["openid", "email"], # scopes Claude will request
resource_name="my-connector",
)
mcp = FastMCP("my-connector", auth=auth)
Do not put a path in base_url. It is joined with the path you pass to mcp.http_app(path=...) later to form the resource.
Writing a tool
The token from an authenticated request is available through get_access_token(). The introspection response lands in claims as-is, so read the login name from there.
from fastmcp.server.dependencies import get_access_token
@mcp.tool
def get_login_name() -> dict:
"""Return the login name of the authenticated user."""
token = get_access_token()
claims = token.claims or {}
return {
"login_name": claims.get("username"),
"scopes": list(token.scopes or []),
}
Build the return value from an allowlist. It is tempting to write return claims, but then any field the authorization server adds later flows straight into Claude's context. Pick the fields you are willing to expose, explicitly.
Add a health check path too. It has to sit in front of authentication, so make it a custom route rather than a tool.
from starlette.requests import Request
from starlette.responses import PlainTextResponse
@mcp.custom_route("/healthz", methods=["GET"])
async def healthz(request: Request) -> PlainTextResponse:
return PlainTextResponse("ok")
Turning it into an ASGI app
http_app() gives you a Starlette app. The path you pass here is the MCP endpoint, and it is joined with base_url to form the resource.
app = mcp.http_app(path="/mcp")
fix_authorization_server_issuer(app, ["https://id.example.com"]) # below
add_root_metadata_fallback(app, "/mcp") # below (in this order)
uvicorn --host 0.0.0.0 --port 8000 --workers 1 --no-proxy-headers main:app
There are reasons for --workers 1 and --no-proxy-headers (see "Operational notes").
The metadata path
Per RFC 9728 §3, if the resource is https://mcp.example.com/mcp, the metadata lives at
/.well-known/oauth-protected-resource/mcp
The spec says the well-known URI string is inserted between the host component and the path component. It is not at the root.
Some clients do go looking at the root, though, so serving the same document there as well is safer. Reuse the same endpoint function so the two paths can never drift apart.
from starlette.routing import Route
WELL_KNOWN = "/.well-known/oauth-protected-resource"
def add_root_metadata_fallback(app, mcp_path: str) -> None:
canonical = f"{WELL_KNOWN}{mcp_path}"
for route in app.routes:
if getattr(route, "path", None) == canonical:
app.router.routes.append(
Route(WELL_KNOWN, endpoint=route.endpoint, methods=["GET", "OPTIONS"])
)
return
raise RuntimeError(f"metadata route not found: {canonical}")
Watch the trailing slash on authorization_servers
The MCP SDK normalizes URLs through pydantic's AnyHttpUrl, which appends a trailing slash to URLs that have no path.
{"authorization_servers": ["https://id.example.com/"]}
Meanwhile the issuer your OIDC provider announces is usually https://id.example.com, without the slash. RFC 8414 §3.3 requires the issuer to match as a string, so a client that validates strictly will reject this.
The reliable fix is to rewrite the metadata response on the way out. Let the library generate the document; correct it at the exit.
import json
class AuthorizationServerIssuerFix:
"""ASGI wrapper that aligns authorization_servers in the PRM with the issuer."""
def __init__(self, app, issuers: list[str]):
self.app = app
# slash-appended form -> the real issuer
self._fix = {f"{issuer}/": issuer for issuer in issuers}
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
start_message = None
body = b""
async def capture(message):
nonlocal start_message, body
if message["type"] == "http.response.start":
start_message = message
return
if message["type"] == "http.response.body":
body += message.get("body", b"")
if message.get("more_body"):
return
await self._send_fixed(send, start_message, body)
return
# pass unknown message types (extensions) straight through
await send(message)
await self.app(scope, receive, capture)
async def _send_fixed(self, send, start_message, body):
try:
document = json.loads(body)
servers = document.get("authorization_servers")
if isinstance(servers, list):
document["authorization_servers"] = [
self._fix.get(s, s) for s in servers
]
body = json.dumps(document).encode()
except ValueError:
pass # not JSON, leave it alone
headers = [
(k, v) for k, v in start_message["headers"]
if k.lower() != b"content-length"
]
headers.append((b"content-length", str(len(body)).encode()))
await send({**start_message, "headers": headers})
await send({"type": "http.response.body", "body": body})
Only replace entries that match the issuer you configured. Stripping trailing slashes unconditionally would break the canonical form of an issuer that has a path (https://id.example.com/auth). Do not forget to recompute Content-Length.
Now wrap the metadata route with it. There is a trap here.
def fix_authorization_server_issuer(app, issuers: list[str]) -> None:
for route in app.routes:
path = getattr(route, "path", None)
if not path or not path.startswith(WELL_KNOWN):
continue
route.endpoint = AuthorizationServerIssuerFix(route.endpoint, issuers)
# This line is required. Starlette builds the ASGI app from the endpoint
# when the Route is created and keeps it in route.app, so replacing the
# endpoint alone changes nothing that is actually served.
route.app = route.endpoint
Replacing route.endpoint on its own does nothing, because Starlette already built an ASGI app from the endpoint and stored it in route.app when the Route was constructed. Only assigning to route.app as well makes the change take effect.
The order matters too. Do this before adding the root fallback: the fallback copies the canonical route's endpoint by reference, so reversing the order leaves the two paths serving different content.
app = mcp.http_app(path="/mcp")
fix_authorization_server_issuer(app, ["https://id.example.com"])
add_root_metadata_fallback(app, "/mcp")
The OIDC provider side (Django OAuth Toolkit)
Serve the metadata directly under the issuer
DOT is normally mounted at /o/, so discovery only appears at /o/.well-known/openid-configuration. But if your issuer has no path (https://id.example.com), the metadata has to live at the host root.
# urls.py
from oauth2_provider.views import ConnectDiscoveryInfoView, OAuthServerMetadataView
well_known = [
path("openid-configuration", ConnectDiscoveryInfoView.as_view()),
path("oauth-authorization-server", OAuthServerMetadataView.as_view()),
]
urlpatterns = [
path("o/", include(oauth2_urls)),
path(".well-known/", include(well_known)),
]
Register the paths without a trailing slash. Some clients fail if an APPEND_SLASH 301 gets in the way.
Note that when the issuer does have a path, the two specs build the URL differently. RFC 8414 uses /.well-known/oauth-authorization-server/<issuer path>, while OIDC Discovery uses /<issuer path>/.well-known/openid-configuration. With a path-less issuer the two coincide, so it does not come up in this article's example.
If your frontend is served from a CDN or static hosting with only the API pointed at another origin, you also need routing that sends /.well-known/* to the API. Forget that and fixing the Django side will not reach anything.
Registering the OAuth Application
Create it by hand in the Django admin.
| Field | Value |
|---|---|
| Client type | Confidential |
| Authorization grant type | Authorization code |
| Redirect URIs | https://claude.ai/api/mcp/auth_callback |
| Algorithm | RSA with SHA-2 256 (RS256) |
| Skip authorization | Off |
Do not set Algorithm to No OIDC support. Claude arrives with a scope set that includes openid, so oauthlib goes off to mint an ID token.
# oauthlib/openid/connect/core/grant_types/base.py
def add_id_token(self, token, token_handler, request, nonce=None):
# Treat it as normal OAuth 2 auth code request if openid is not present
if not request.scopes or 'openid' not in request.scopes:
return token
There is another branch right after this one that looks at response_type, but a token request in the authorization code flow carries no response_type, so it falls through and proceeds to build the ID token. With No OIDC support, Application.jwk_key ends up None and the token endpoint fails with a 500. The consent screen works and the code exchange dies, which makes the cause hard to spot.
Using RS256 requires OAUTH2_PROVIDER['OIDC_RSA_PRIVATE_KEY'] to be set.
Conversely, if you design the connector to never request openid (verifying only custom scopes via introspection), No OIDC support is the right answer. The algorithm is not a matter of taste; it follows from whether an ID token gets issued.
You do not need to open dynamic client registration
DOT does ship a DCR implementation, but it is disabled by default and its default permission class requires a registration access token. Claude's DCR is anonymous, so supporting it would mean letting anyone on the internet create OAuth Applications.
For a private, internal connector, entering the client_id and client_secret by hand as a custom connector is enough.
Verify tokens with introspection
There are two ways for the MCP server (the resource server) to verify the bearer token it receives.
- Call the UserInfo endpoint (OpenID Connect Core 1.0 §5.3) and see whether it returns 200
- Verify with Token Introspection (RFC 7662)
Both are standards. UserInfo is part of OIDC Core, so most providers have it (though in the discovery metadata it is RECOMMENDED, not required). Introspection is an OAuth 2.0 extension, so it is sometimes not implemented at all.
Neither endpoint has a URL fixed by the spec. Look them up in the discovery documents. The /o/... paths in this article are what you get when Django OAuth Toolkit is mounted at /o/; they differ per implementation.
Note that the two live in different metadata documents. userinfo_endpoint is in OIDC Discovery, introspection_endpoint is in the RFC 8414 metadata. With DOT, the OIDC document does not contain introspection_endpoint.
# UserInfo comes from OIDC Discovery
curl -sS https://id.example.com/.well-known/openid-configuration | jq .userinfo_endpoint
# Introspection comes from the RFC 8414 authorization server metadata
curl -sS https://id.example.com/.well-known/oauth-authorization-server | jq .introspection_endpoint
Choose introspection. There are two reasons.
1. UserInfo cannot be called with a resource-bound token
Following the MCP authorization spec, Claude attaches the RFC 8707 resource parameter to the authorization request.
GET /o/authorize/?...&resource=https%3A%2F%2Fmcp.example.com%2Fmcp
DOT 3.4 stores this in AccessToken.resource and validates the audience every time the token is used — and it is on by default.
# oauth2_provider/settings.py
"RESOURCE_SERVER_TOKEN_RESOURCE_VALIDATOR": (
"oauth2_provider.oauth2_validators.validate_resource_as_url_prefix"
),
# oauth2_provider/oauth2_validators.py validate_bearer_token
if access_token.resource:
request_uri = (getattr(request, "uri", None) or "").split("?")[0]
if not access_token.allows_audience(request_uri):
... ("error", "invalid_token") ... return False
A token bound to https://mcp.example.com/mcp, used against the authorization server's own endpoint (userinfo_endpoint), obviously does not match. You get a 401 with invalid_token. That is the authorization server behaving correctly.
On an authorization server that does not validate the audience, the same call will work. But that only means nothing is checking; using a token issued for a resource server against the authorization server's own endpoint goes against the intent of RFC 8707. It works today and breaks the day your provider implements the check.
2. Introspection gives you audience, scope and expiry all at once
Everything you need to verify is in the response.
{
"active": true,
"scope": "openid email",
"exp": 1785000000,
"client_id": "...",
"username": "user@example.com",
"aud": ["https://mcp.example.com/mcp"]
}
You do not need an introspection scope
DOT's IntrospectTokenView declares required_scopes = ["introspection"], but that is never evaluated when you authenticate with HTTP Basic.
# oauth2_provider/views/mixins.py ClientProtectedResourceMixin.dispatch
valid = self.authenticate_client(request) # HTTP Basic is tried first
if not valid:
valid, r = self.verify_request(request) # required_scopes only applies here
And authenticate_client looks only at whether the client_id exists and the client_secret matches — not the grant type, not the scopes. You do not have to add a scope on the authorization server.
RFC 7662 §2.1 also allows protecting the endpoint with client authentication, so this is spec-compliant, but it does depend on the order in which DOT's dispatch evaluates things. Test it for real after a version bump.
Use two sets of credentials
Register two OAuth Applications.
| Application | Purpose | Grant type | Where the client_secret lives |
|---|---|---|---|
| Client | Claude authorizes the user | Authorization code |
Only in Claude's connector settings |
| Resource server | The MCP server calls introspection | Client credentials |
Only in the MCP server's environment |
A single shared Application works, but there are three reasons to split them.
- The roles differ. The first is an OAuth client; the second is how the resource server identifies itself. Sharing means the MCP server's pod holds credentials that can act as an OAuth client
- Rotation is decoupled. Sharing forces you to update Claude's settings and the server's configuration at the same time whenever you rotate the secret; update only one and something breaks
- Fewer copies of the secret. You avoid having the same value stored in two places
The resource server Application accepts essentially any grant type, but Client credentials makes its role obvious at a glance. It needs no redirect URI.
Security notes
Always validate the audience
This is the single most important item. A successful introspection only tells you the token is valid and was issued by your authorization server. A token issued for a completely different application will pass too. To satisfy the MCP authorization spec's requirement not to accept tokens that are not addressed to you, you have to look at aud yourself.
FastMCP's IntrospectionTokenVerifier only checks active, exp and required_scopes, so subclass it and add the rest.
The decision has three branches.
- If
audis present, require that your own resource (base_url+ the MCP path) is in it, matched exactly - If
audis absent, require thatclient_idmatches the client Application (see below) - If neither can be decided, reject
import logging
logger = logging.getLogger(__name__)
class ResourceBoundIntrospectionTokenVerifier(IntrospectionTokenVerifier):
def __init__(self, *, resource: str, expected_client_id: str, **kwargs):
super().__init__(**kwargs)
self._resource = resource
self._expected_client_id = expected_client_id
async def verify_token(self, token):
access_token = await super().verify_token(token)
if access_token is None:
return None
error = self._binding_error(access_token.claims or {})
if error is not None:
logger.warning("rejected a token that is not for this resource: %s", error)
return None
return access_token
def _binding_error(self, claims: dict) -> str | None:
audiences = _parse_audiences(claims.get("aud"))
if audiences is None:
return "unparsable aud claim"
if audiences:
if self._resource in audiences:
return None
return f"audience mismatch: {audiences}"
if claims.get("client_id") == self._expected_client_id:
return None
return "client_id mismatch and no audience to fall back on"
def _parse_audiences(value) -> list[str] | None:
"""Normalize aud into a list of strings. None if the type is unusable."""
if value is None:
return []
if isinstance(value, str):
return [value] if value.strip() else []
if isinstance(value, list):
return [str(item) for item in value if item]
return None
Do not match partially. A prefix match confuses /mcp with /mcp-admin. Treat subdomains as different too.
The second branch has real limits. The client_id in an introspection response identifies the OAuth client that requested the token, not the audience (RFC 7662 §2.2). A match does not prove the token was issued for you. It only carries meaning under the operational assumption that the client Application is registered exclusively for this MCP server. It is not equivalent to RFC 8707 audience validation.
It is worth keeping anyway: if a client ever stops sending resource, you still hold the guarantee that tokens for other applications are rejected. As long as resource is sent, branch 1 decides.
An aud whose type is neither a string nor an array is rejected as well (the path where _parse_audiences returns None). Degrading a parse failure into "unrestricted" turns an anomaly on the authorization server into a silent bypass of your audience check.
An empty aud ([]), on the other hand, should be treated as unrestricted. This is a DOT-specific compatibility choice: internally DOT defines the empty list as "not restricted to any particular resource server", and the current implementation omits aud from the response entirely when it is empty. Treating it as unrestricted is insurance against a future version that starts including it.
Note that under the JWT rules (RFC 7519 §4.1.3), if aud is present you reject unless you are in it. Letting an empty array through departs from that rule, so if your provider gives the empty array a different meaning, follow your provider.
Authentication and authorization are different things
"Can authenticate" and "may use this server" are not the same. If your OIDC provider also accepts sign-ups from outside your organization, authentication alone is not enough.
Two practical filters:
- The domain of the login name (email) — decided from the
usernamein the introspection response - Organization membership — if you have a notion of organizations, require membership
Before filtering on the domain, check what username actually means in your provider. The example here works because USERNAME_FIELD is the email address and ownership of that address is verified at sign-up. Basing authorization on a login name users can set freely invites impersonation.
Domain checks leak if written carelessly.
- Match the domain part exactly. A suffix or substring match lets
attacker@evil-example.comthrough. Treat subdomains (user@sub.example.com) as different too - Reject anything with two or more
@. If you split on the last@, the second half ofoutsider@other.com@example.comis read as an allowed domain - Compare in lowercase (DNS domain names are case-insensitive)
- Do not trim whitespace before comparing.
user@ example.comwould pass
A regular expression is the safe way to pin this down.
import re
_EMAIL_PATTERN = re.compile(
r"^[^@\s]+@((?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,})$",
re.IGNORECASE,
)
For organization membership, the standard introspection response carries nothing about organizations. RFC 7662 allows adding your own members, so an extension claim is the natural place. With DOT, subclass IntrospectTokenView and register it before include(oauth2_urls).
# urls.py — must come before include(oauth2_urls); resolution is first-match
urlpatterns = [
path("o/introspect/", CustomIntrospectTokenView.as_view(), name="introspect"),
path("o/", include(oauth2_urls)),
]
import hashlib
import json
from django.http import JsonResponse
from oauth2_provider.models import get_access_token_model
from oauth2_provider.views import IntrospectTokenView as BaseIntrospectTokenView
class CustomIntrospectTokenView(BaseIntrospectTokenView):
@staticmethod
def get_token_response(token_value=None):
response = BaseIntrospectTokenView.get_token_response(token_value)
if response.status_code != 200:
return response
data = json.loads(response.content)
if not data.get("active"):
return response
user_id = _get_token_user_id(token_value)
if user_id is None:
return response # omit the claim; the resource server will reject
data["organization_ids"] = [
str(organization_id)
for organization_id in Organization.objects.filter(
organizationuser__user_id=user_id,
organizationuser__status=ACCEPTED,
).values_list("id", flat=True)
]
return JsonResponse(data)
def _get_token_user_id(token_value):
# DOT indexes tokens by token_checksum (a SHA-256 hexdigest)
checksum = hashlib.sha256(token_value.encode("utf-8")).hexdigest()
return (
get_access_token_model()
.objects.filter(token_checksum=checksum)
.values_list("user_id", flat=True)
.first()
)
Do not rewrite the library's logic wholesale. Taking its result and adding to it means you keep up when the library adds response fields (aud was added in DOT 3.4).
get_token_response only receives the token string, so you have to look the user up again. DOT indexes tokens by token_checksum (a SHA-256 hexdigest), so use the same method. This is a spot that needs to follow the library if it changes how it builds that index, so pin it down with a test.
Note that DOT stores access tokens in plaintext by default. token_checksum is an index, not hashed-at-rest storage. If you want to avoid keeping plaintext, in line with RFC 9700, enable COMPLIANT_BCP_RFC9700_TOKEN_STORAGE (off by default).
Watch the membership state as well. Filter by status so that users who were merely invited and have not accepted do not count as members.
When you cannot decide, reject
Keep this consistent as a design principle.
| Situation | Behaviour |
|---|---|
username missing, or not an email address |
Reject |
| Extension claim missing (provider not deployed yet) | Reject |
| Claim has an unexpected type | Reject |
| Configuration value invalid (not a UUID, etc.) | Fail at startup |
Falling back to "cannot decide, so allow" silently disables the restriction. Validating configuration at startup follows the same logic: failing to start beats running with a wrong configuration you never notice.
If you do make the restriction removable by emptying the allowlist, log a warning at startup when it is empty so the situation is noticeable.
Let the name convey what the allowlist means
When an allowlist can hold several values, make it clear from the name whether the list is OR (any one) or AND (all of them). A name like REQUIRED_ORGANIZATION_IDS reads as "must belong to every listed organization", but if the implementation uses intersection (any-of), the meaning is the opposite.
Someone adding a second value to "tighten the rule" would instead widen what is allowed. Prefixing with ALLOWED_ is the safer choice.
Rejections come back as 401, not 403
Know this as a constraint of the framework. FastMCP offers no way to reject other than returning None from verify_token. Even an authorization failure — this user may not use the server — reaches the client as a 401.
The same goes for missing scopes. IntrospectionTokenVerifier checks required_scopes itself and returns None, so the MCP SDK's 403 insufficient_scope path is never reached.
As a result, a user who is not allowed in sees a repeating re-authentication loop. Log the reason for the rejection on the server so you can tell what happened.
Cached results delay revocation
Introspection costs a round trip to the authorization server per token, which makes caching tempting. FastMCP's IntrospectionTokenVerifier has cache_ttl_seconds for that (disabled by default).
While a result is cached, token revocation is not reflected. A user disconnecting the integration, or an administrator revoking a token, takes effect up to the TTL later. Real-time revocation is one of introspection's advantages, and a longer TTL trades it away.
If you need revocation to land within seconds, set it to 0. Otherwise, a few tens of seconds is a reasonable ceiling.
Do not emit secrets or personal data
- Never log the access token value, and never return it from a tool. Access logs should record only
present/absent, and query strings only by key name. The current Best Current Practice, RFC 9700 §4.3.2, says tokens MUST NOT be sent in the URI query (RFC 6750 called it NOT RECOMMENDED), and the MCP authorization spec forbids it as well — but implement your logging so that a client that sends one anyway does not leave the value behind - Build tool return values from an allowlist. Returning the whole introspection response leaks whatever the provider adds later. Choose the fields to return, and expose the rest as a list of key names only
- Do not write personally identifying data into rejection logs. The domain is enough to diagnose
- Do not dump the output of commands that may contain secrets. It ends up in terminal scrollback, terminal logs, screen shares and AI agent transcripts. When you just want to confirm which keys are set, reduce it to key names
your-env-dump-command | sed -E 's/=.*/=<set>/'
Introspection responses are not gated by consent
This is worth knowing. Any Application holding a valid client_id / client_secret pair can call the introspection endpoint. DOT's Basic authentication does not even look at the client type; it checks that the client_id exists and the secret matches. It never checks the relationship between the caller and the token — not the audience, not whether the token was issued to that caller.
And DOT's standard introspection returns username (the user's email) regardless of whether the email scope was granted.
if token.user:
data["username"] = token.user.get_username()
In other words, the scope-consent gate that exists on the UserInfo side does not apply to introspection. When you add an extension claim, assume it is readable by every confidential Application that holds the token string.
If you have opened self-service application registration (DCR), or you issue client credentials to third parties, you need to restrict the claim to an allowlist of resource server client_ids, or gate it behind a scope.
Restrict the source IP
Connections from Claude come from Anthropic's outbound range. With ingress-nginx:
nginx.ingress.kubernetes.io/whitelist-source-range: "160.79.104.0/21"
Do not confuse this with the inbound range 160.79.104.0/23. They are different. Use the outbound IPv4 range from the official documentation.
As a prerequisite, the ingress has to be able to see the real client IP. With a cloud load balancer in front, and without externalTrafficPolicy: Local or proxy protocol, the ingress only sees the post-SNAT address of the load balancer. Applying an allowlist then either blocks everything or lets the load balancer's address through, making it useless.
Before enabling it, get one connection to succeed and confirm the actual source in your access log. Do it the other way around and you will not be able to tell whether a failure comes from the IP restriction or from OAuth.
Once enabled, curl from your own machine gets a 403 as well. And requests dropped at the ingress leave no trace in the application's access log. When things stop working, look at the ingress log first.
Operational notes
Keep it to one process
With FastMCP's default (stateful) mode, the Streamable HTTP session lives inside the process. Multiple workers or replicas mean the process that issued the mcp-session-id and the process receiving the next request disagree, and the session cannot be found.
(FastMCP also offers stateless_http=True, which builds a transport per request and lifts this constraint. What follows assumes the stateful setup used in this article.)
uvicorn --workers 1 --no-proxy-headers main:app
On Kubernetes, set replicas: 1 and also strategy: Recreate. With RollingUpdate, an old and a new pod are both behind the Service during the update, which causes the same problem.
--no-proxy-headers is there for a different reason. uvicorn enables proxy_headers by default, which overwrites request.client with the value of X-Forwarded-For. That makes it impossible to distinguish "the peer IP the ingress connected from" from "the IP the client claimed", so turn it off if you want to verify the source.
Do not let SSE be buffered
Most reverse proxies buffer responses by default. That stops text/event-stream from flowing, so turn it off. The stream also has quiet periods, so raise the read timeout.
nginx.ingress.kubernetes.io/proxy-buffering: "off"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
Checking the connection
Once deployed, poke at it yourself before registering it in Claude.
# Does it return 401 with WWW-Authenticate?
curl -sS -D - -o /dev/null -X POST https://mcp.example.com/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
# Protected resource metadata
curl -sS https://mcp.example.com/.well-known/oauth-protected-resource/mcp
# Is the authorization server metadata under the issuer?
curl -sS https://id.example.com/.well-known/openid-configuration
The metadata should look like this. Check with your own eyes that resource still carries the MCP path, and that authorization_servers matches the issuer as a string.
{
"resource": "https://mcp.example.com/mcp",
"authorization_servers": ["https://id.example.com"],
"scopes_supported": ["openid", "email"],
"bearer_methods_supported": ["header"]
}
Confirm the introspection client authentication up front as well. If a non-existent token comes back as {"active": false}, authentication itself is working (wrong credentials give you a 403).
curl -sS -u "$RS_CLIENT_ID:$RS_CLIENT_SECRET" \
-X POST https://id.example.com/o/introspect/ -d 'token=dummy'
After that, go to Claude's settings → Connectors → Add custom connector, enter https://mcp.example.com/mcp as the URL, and put the client Application's client_id and client_secret in the advanced settings.
The URL must match the resource in your metadata exactly. The spec requires a match, not the absence of a trailing slash, but since the URL the user types, the MCP endpoint and the metadata resource all have to line up, standardizing on no trailing slash gives you the fewest accidents.
We look forward to discussing your development needs.